Compare commits

..
1 Commits
Author SHA1 Message Date
LukasandClaude Opus 4.6 e44e56b09b Add PF2e equipment display with detail popovers in stat blocks
CI / check (push) Successful in 2m31s
CI / build-image (push) Successful in 19s
Extract shared DetailPopover shell from spell popovers. Normalize
weapon/consumable/equipment/armor items from Foundry data into
mundane (Items line) and detailed (Equipment section with clickable
popovers). Scrolls/wands show embedded spell info. Bump IDB cache v7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:21:11 +02:00
108 changed files with 820 additions and 9437 deletions
-148
View File
@@ -1,148 +0,0 @@
---
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 81 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.
+3 -9
View File
@@ -1,14 +1,8 @@
{ {
"threshold": 3, "threshold": 5,
"minLines": 5, "minLines": 5,
"minTokens": 50, "minTokens": 50,
"pattern": "**/*.{ts,tsx}", "pattern": ["**/*.ts", "**/*.tsx"],
"ignore": [ "ignore": ["node_modules", "dist", "build", "coverage", ".specify", "specs"],
"**/node_modules/**",
"**/dist/**",
"**/build/**",
"**/coverage/**",
"**/__tests__/**"
],
"reporters": ["console"] "reporters": ["console"]
} }
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-type-annotations/refs/heads/main/packages/oxlint/configuration_file_schema.json", "$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-type-annotations/refs/heads/main/packages/oxlint/configuration_file_schema.json",
"plugins": ["typescript", "unicorn", "jest", "import"], "plugins": ["typescript", "unicorn", "jest"],
"categories": {}, "categories": {},
"rules": { "rules": {
"typescript/no-unnecessary-type-assertion": "error", "typescript/no-unnecessary-type-assertion": "error",
@@ -8,7 +8,6 @@
"typescript/prefer-regexp-exec": "error", "typescript/prefer-regexp-exec": "error",
"unicorn/prefer-string-replace-all": "error", "unicorn/prefer-string-replace-all": "error",
"unicorn/prefer-string-raw": "error", "unicorn/prefer-string-raw": "error",
"import/no-cycle": "error",
"jest/expect-expect": [ "jest/expect-expect": [
"error", "error",
{ {
@@ -22,6 +21,7 @@
".claude", ".claude",
".specify", ".specify",
"specs", "specs",
".pnpm-store" ".pnpm-store",
"scripts"
] ]
} }
+2 -2
View File
@@ -27,8 +27,8 @@
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"jsdom": "^30.0.1", "jsdom": "^29.0.1",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"vite": "^8.0.16" "vite": "^8.0.5"
} }
} }
@@ -116,7 +116,6 @@ export function createTestAdapters(options?: {
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`, `https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
getSourceDisplayName: (sourceCode) => sourceCode, getSourceDisplayName: (sourceCode) => sourceCode,
getCreaturePathsForSource: () => [], getCreaturePathsForSource: () => [],
getCreatureNamesByPaths: () => new Map(),
}, },
}; };
} }
@@ -266,45 +266,6 @@ describe("round-trip: export then import", () => {
expect(imported.encounter.combatants[1].side).toBe("enemy"); expect(imported.encounter.combatants[1].side).toBe("enemy");
}); });
it("round-trips a combatant with hpVariant field", () => {
const encounterWithVariant: Encounter = {
combatants: [
{
id: combatantId("c-1"),
name: "Ogre",
maxHp: 24,
currentHp: 24,
hpVariant: "max",
},
{
id: combatantId("c-2"),
name: "Goblin",
maxHp: 2,
currentHp: 2,
hpVariant: "min",
},
],
activeIndex: 0,
roundNumber: 1,
};
const emptyUndoRedo: UndoRedoState = {
undoStack: [],
redoStack: [],
};
const bundle = assembleExportBundle(
encounterWithVariant,
emptyUndoRedo,
[],
);
const serialized = JSON.parse(JSON.stringify(bundle));
const result = validateImportBundle(serialized);
expect(typeof result).toBe("object");
const imported = result as ExportBundle;
expect(imported.encounter.combatants[0].hpVariant).toBe("max");
expect(imported.encounter.combatants[1].hpVariant).toBe("min");
});
it("round-trips a combatant without side field as undefined", () => { it("round-trips a combatant without side field as undefined", () => {
const encounterNoSide: Encounter = { const encounterNoSide: Encounter = {
combatants: [{ id: combatantId("c-1"), name: "Custom" }], combatants: [{ id: combatantId("c-1"), name: "Custom" }],
@@ -1,28 +0,0 @@
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,4 +1,3 @@
export { buildCombatant } from "./build-combatant.js"; export { buildCombatant } from "./build-combatant.js";
export { buildCreature } from "./build-creature.js"; export { buildCreature } from "./build-creature.js";
export { buildEncounter } from "./build-encounter.js"; export { buildEncounter } from "./build-encounter.js";
export { buildPf2eCreature } from "./build-pf2e-creature.js";
@@ -16,18 +16,12 @@ vi.mock("../contexts/bestiary-context.js", () => ({
useBestiaryContext: vi.fn(), useBestiaryContext: vi.fn(),
})); }));
vi.mock("../contexts/encounter-context.js", () => ({
useEncounterContext: vi.fn(),
}));
import { StatBlockPanel } from "../components/stat-block-panel.js"; import { StatBlockPanel } from "../components/stat-block-panel.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js"; import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js"; import { useSidePanelContext } from "../contexts/side-panel-context.js";
const mockUseSidePanelContext = vi.mocked(useSidePanelContext); const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
const mockUseBestiaryContext = vi.mocked(useBestiaryContext); const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
const mockUseEncounterContext = vi.mocked(useEncounterContext);
const CLOSE_REGEX = /close/i; const CLOSE_REGEX = /close/i;
const COLLAPSE_REGEX = /collapse/i; const COLLAPSE_REGEX = /collapse/i;
@@ -88,7 +82,6 @@ function setupMocks(overrides: PanelOverrides = {}) {
mockUseSidePanelContext.mockReturnValue({ mockUseSidePanelContext.mockReturnValue({
selectedCreatureId: panelRole === "browse" ? creatureId : null, selectedCreatureId: panelRole === "browse" ? creatureId : null,
selectedCombatantId: null,
pinnedCreatureId: panelRole === "pinned" ? creatureId : null, pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false, isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
isWideDesktop: false, isWideDesktop: false,
@@ -117,11 +110,6 @@ function setupMocks(overrides: PanelOverrides = {}) {
refreshCache: vi.fn(), refreshCache: vi.fn(),
} as ReturnType<typeof useBestiaryContext>); } as ReturnType<typeof useBestiaryContext>);
mockUseEncounterContext.mockReturnValue({
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
setCreatureAdjustment: vi.fn(),
} as unknown as ReturnType<typeof useEncounterContext>);
return { onToggleCollapse, onPin, onUnpin, onDismiss }; return { onToggleCollapse, onPin, onUnpin, onDismiss };
} }
@@ -49,9 +49,10 @@ describe("loadBestiaryIndex", () => {
}); });
describe("getAllSourceCodes", () => { describe("getAllSourceCodes", () => {
it("returns all index sources except bundled ones", () => { it("returns all keys from the index sources", () => {
const codes = getAllSourceCodes(); const codes = getAllSourceCodes();
expect(codes).not.toContain("TGL"); const index = loadBestiaryIndex();
expect(codes).toEqual(Object.keys(index.sources));
}); });
it("returns only strings", () => { it("returns only strings", () => {
@@ -1,45 +0,0 @@
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,39 +131,6 @@ describe("normalizeFoundryCreature", () => {
); );
expect(creature.senses).toBe("Scent 60 feet"); expect(creature.senses).toBe("Scent 60 feet");
}); });
it("extracts perception details", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
system: {
...minimalCreature().system,
perception: {
mod: 35,
details: "smoke vision",
senses: [{ type: "darkvision" }],
},
},
}),
);
expect(creature.perceptionDetails).toBe("smoke vision");
expect(creature.senses).toBe("Darkvision");
});
it("omits perception details when empty", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
system: {
...minimalCreature().system,
perception: {
mod: 8,
details: "",
senses: [{ type: "darkvision" }],
},
},
}),
);
expect(creature.perceptionDetails).toBeUndefined();
});
}); });
describe("languages formatting", () => { describe("languages formatting", () => {
@@ -419,101 +386,6 @@ describe("normalizeFoundryCreature", () => {
}), }),
); );
}); });
it("includes attack effects in damage text", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "atk1",
name: "talon",
type: "melee",
system: {
bonus: { value: 14 },
damageRolls: {
abc: {
damage: "1d10+6",
damageType: "piercing",
},
},
traits: { value: [] },
attackEffects: { value: ["grab"] },
},
},
],
}),
);
const attack = creature.attacks?.[0];
expect(attack?.segments[0]).toEqual({
type: "text",
value: "+14, 1d10+6 piercing plus Grab",
});
});
it("joins multiple attack effects with 'and'", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "atk1",
name: "claw",
type: "melee",
system: {
bonus: { value: 18 },
damageRolls: {
abc: {
damage: "2d8+6",
damageType: "slashing",
},
},
traits: { value: [] },
attackEffects: {
value: ["grab", "knockdown"],
},
},
},
],
}),
);
const attack = creature.attacks?.[0];
expect(attack?.segments[0]).toEqual({
type: "text",
value: "+18, 2d8+6 slashing plus Grab and Knockdown",
});
});
it("strips creature-name prefix from attack effect slugs", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
name: "Lich",
items: [
{
_id: "atk1",
name: "hand",
type: "melee",
system: {
bonus: { value: 24 },
damageRolls: {
abc: {
damage: "2d12+7",
damageType: "negative",
},
},
traits: { value: [] },
attackEffects: {
value: ["lich-siphon-life"],
},
},
},
],
}),
);
const attack = creature.attacks?.[0];
expect(attack?.segments[0]).toEqual({
type: "text",
value: "+24, 2d12+7 negative plus Siphon Life",
});
});
}); });
describe("ability normalization", () => { describe("ability normalization", () => {
@@ -667,114 +539,6 @@ describe("normalizeFoundryCreature", () => {
: undefined, : undefined,
).toBe("(Concentrate, Polymorph) Takes a new form."); ).toBe("(Concentrate, Polymorph) Takes a new form.");
}); });
it("extracts frequency from ability", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "a1",
name: "Drain Soul Cage",
type: "action",
system: {
category: "offensive",
actionType: { value: "free" },
actions: { value: null },
traits: { value: [] },
description: { value: "<p>Drains the soul.</p>" },
frequency: { max: 1, per: "day" },
},
},
],
}),
);
expect(creature.abilitiesBot?.[0]?.frequency).toBe("1/day");
});
it("strips redundant frequency line from description", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "a1",
name: "Consult the Text",
type: "action",
system: {
category: "offensive",
actionType: { value: "action" },
actions: { value: 1 },
traits: { value: [] },
description: {
value:
"<p><strong>Frequency</strong> once per day</p>\n<hr />\n<p><strong>Effect</strong> The lich opens their spell tome.</p>",
},
frequency: { max: 1, per: "day" },
},
},
],
}),
);
const text =
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
? creature.abilitiesBot[0].segments[0].value
: "";
expect(text).not.toContain("Frequency");
expect(text).toContain("The lich opens their spell tome.");
});
it("strips frequency line even when preceded by other text", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "a1",
name: "Drain Soul Cage",
type: "action",
system: {
category: "offensive",
actionType: { value: "free" },
actions: { value: null },
traits: { value: [] },
description: {
value:
"<p>6th rank</p>\n<hr />\n<p><strong>Frequency</strong> once per day</p>\n<hr />\n<p><strong>Effect</strong> The lich taps into their soul cage.</p>",
},
frequency: { max: 1, per: "day" },
},
},
],
}),
);
const text =
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
? creature.abilitiesBot[0].segments[0].value
: "";
expect(text).not.toContain("Frequency");
expect(text).toContain("6th rank");
expect(text).toContain("The lich taps into their soul cage.");
});
it("omits frequency when not present", () => {
const creature = normalizeFoundryCreature(
minimalCreature({
items: [
{
_id: "a1",
name: "Strike",
type: "action",
system: {
category: "offensive",
actionType: { value: "action" },
actions: { value: 1 },
traits: { value: [] },
description: { value: "<p>Strikes.</p>" },
},
},
],
}),
);
expect(creature.abilitiesBot?.[0]?.frequency).toBeUndefined();
});
}); });
describe("equipment normalization", () => { describe("equipment normalization", () => {
@@ -1115,8 +879,7 @@ describe("normalizeFoundryCreature", () => {
expect(sc?.daily?.[0]?.spells.map((s) => s.name)).toEqual(["Earthquake"]); expect(sc?.daily?.[0]?.spells.map((s) => s.name)).toEqual(["Earthquake"]);
expect(sc?.daily?.[1]?.spells.map((s) => s.name)).toEqual(["Heal"]); expect(sc?.daily?.[1]?.spells.map((s) => s.name)).toEqual(["Heal"]);
expect(sc?.atWill?.map((s) => s.name)).toEqual(["Detect Magic"]); expect(sc?.atWill?.map((s) => s.name)).toEqual(["Detect Magic"]);
// Cantrip rank auto-heightens to ceil(creatureLevel / 2) = ceil(3/2) = 2 expect(sc?.atWill?.[0]?.rank).toBe(1);
expect(sc?.atWill?.[0]?.rank).toBe(2);
}); });
it("normalizes innate spells with uses", () => { it("normalizes innate spells with uses", () => {
@@ -99,15 +99,9 @@ describe("stripFoundryTags", () => {
expect(stripFoundryTags("before<hr />after")).toBe("before\nafter"); expect(stripFoundryTags("before<hr />after")).toBe("before\nafter");
}); });
it("preserves strong and em tags", () => { it("strips strong and em tags", () => {
expect(stripFoundryTags("<strong>bold</strong> <em>italic</em>")).toBe( expect(stripFoundryTags("<strong>bold</strong> <em>italic</em>")).toBe(
"<strong>bold</strong> <em>italic</em>", "bold italic",
);
});
it("preserves list tags", () => {
expect(stripFoundryTags("<ul><li>first</li><li>second</li></ul>")).toBe(
"<ul><li>first</li><li>second</li></ul>",
); );
}); });
+2 -2
View File
@@ -3,8 +3,8 @@ import { type IDBPDatabase, openDB } from "idb";
const DB_NAME = "initiative-bestiary"; const DB_NAME = "initiative-bestiary";
const STORE_NAME = "sources"; const STORE_NAME = "sources";
// v8 (2026-04-10): Attack effects, ability frequency, perception details added to PF2e creatures // v7 (2026-04-10): Equipment items added to PF2e creatures; old caches are cleared
const DB_VERSION = 8; const DB_VERSION = 7;
interface CachedSourceInfo { interface CachedSourceInfo {
readonly sourceCode: string; readonly sourceCode: string;
@@ -1,10 +1,6 @@
import type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain"; import type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain";
import rawIndex from "../../../../data/bestiary/index.json"; import rawIndex from "../../../../data/bestiary/index.json";
import {
getBundledDndSources,
loadBundledDndIndexEntries,
} from "./dnd-bundled-adapter.js";
interface CompactCreature { interface CompactCreature {
readonly n: string; readonly n: string;
@@ -59,32 +55,23 @@ export function loadBestiaryIndex(): BestiaryIndex {
if (cachedIndex) return cachedIndex; if (cachedIndex) return cachedIndex;
const compact = rawIndex as unknown as CompactIndex; const compact = rawIndex as unknown as CompactIndex;
const sources: Record<string, string> = Object.fromEntries( const sources = Object.fromEntries(
Object.entries(compact.sources).filter( Object.entries(compact.sources).filter(
([code]) => !EXCLUDED_SOURCES.has(code), ([code]) => !EXCLUDED_SOURCES.has(code),
), ),
); );
for (const [code, name] of getBundledDndSources()) {
sources[code] = name;
}
cachedIndex = { cachedIndex = {
sources, sources,
creatures: [ creatures: compact.creatures
...compact.creatures
.filter((c) => !EXCLUDED_SOURCES.has(c.s)) .filter((c) => !EXCLUDED_SOURCES.has(c.s))
.map(mapCreature), .map(mapCreature),
...loadBundledDndIndexEntries(),
],
}; };
return cachedIndex; return cachedIndex;
} }
export function getAllSourceCodes(): string[] { export function getAllSourceCodes(): string[] {
const index = loadBestiaryIndex(); const index = loadBestiaryIndex();
const bundled = getBundledDndSources(); return Object.keys(index.sources).filter((c) => !EXCLUDED_SOURCES.has(c));
return Object.keys(index.sources).filter(
(c) => !EXCLUDED_SOURCES.has(c) && !bundled.has(c),
);
} }
function sourceCodeToFilename(sourceCode: string): string { function sourceCodeToFilename(sourceCode: string): string {
@@ -1,53 +0,0 @@
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;
}
+10 -61
View File
@@ -63,7 +63,6 @@ interface MeleeSystem {
bonus?: { value: number }; bonus?: { value: number };
damageRolls?: Record<string, { damage: string; damageType: string }>; damageRolls?: Record<string, { damage: string; damageType: string }>;
traits?: { value: string[] }; traits?: { value: string[] };
attackEffects?: { value: string[] };
} }
interface ActionSystem { interface ActionSystem {
@@ -72,7 +71,6 @@ interface ActionSystem {
actions?: { value: number | null }; actions?: { value: number | null };
traits?: { value: string[] }; traits?: { value: string[] };
description?: { value: string }; description?: { value: string };
frequency?: { max: number; per: string };
} }
interface SpellcastingEntrySystem { interface SpellcastingEntrySystem {
@@ -344,17 +342,7 @@ function formatSpeed(speed: {
// -- Attack normalization -- // -- Attack normalization --
/** Format an attack effect slug to display text: "grab" → "Grab", "lich-siphon-life" → "Siphon Life". */ function normalizeAttack(item: RawFoundryItem): TraitBlock {
function formatAttackEffect(slug: string, creatureName: string): string {
const prefix = `${creatureName.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-`;
const stripped = slug.startsWith(prefix) ? slug.slice(prefix.length) : slug;
return stripped.split("-").map(capitalize).join(" ");
}
function normalizeAttack(
item: RawFoundryItem,
creatureName: string,
): TraitBlock {
const sys = item.system as unknown as MeleeSystem; const sys = item.system as unknown as MeleeSystem;
const bonus = sys.bonus?.value ?? 0; const bonus = sys.bonus?.value ?? 0;
const traits = sys.traits?.value ?? []; const traits = sys.traits?.value ?? [];
@@ -364,18 +352,13 @@ function normalizeAttack(
.join(" plus "); .join(" plus ");
const traitStr = const traitStr =
traits.length > 0 ? ` (${traits.map(formatTrait).join(", ")})` : ""; traits.length > 0 ? ` (${traits.map(formatTrait).join(", ")})` : "";
const effects = sys.attackEffects?.value ?? [];
const effectStr =
effects.length > 0
? ` plus ${effects.map((e) => formatAttackEffect(e, creatureName)).join(" and ")}`
: "";
return { return {
name: capitalize(item.name), name: capitalize(item.name),
activity: { number: 1, unit: "action" }, activity: { number: 1, unit: "action" },
segments: [ segments: [
{ {
type: "text", type: "text",
value: `+${bonus}${traitStr}, ${damage}${effectStr}`, value: `+${bonus}${traitStr}, ${damage}`,
}, },
], ],
}; };
@@ -399,31 +382,15 @@ function parseActivity(
// -- Ability normalization -- // -- Ability normalization --
const FREQUENCY_LINE = /(<strong>)?Frequency(<\/strong>)?\s+[^\n]+\n*/i;
/** Strip the "Frequency once per day" line from ability descriptions when structured frequency data exists. */
function stripFrequencyLine(text: string): string {
return text.replace(FREQUENCY_LINE, "").trimStart();
}
function normalizeAbility(item: RawFoundryItem): TraitBlock { function normalizeAbility(item: RawFoundryItem): TraitBlock {
const sys = item.system as unknown as ActionSystem; const sys = item.system as unknown as ActionSystem;
const actionType = sys.actionType?.value; const actionType = sys.actionType?.value;
const actionCount = sys.actions?.value; const actionCount = sys.actions?.value;
let description = stripFoundryTags(sys.description?.value ?? ""); const description = stripFoundryTags(sys.description?.value ?? "");
const traits = sys.traits?.value ?? []; const traits = sys.traits?.value ?? [];
const activity = parseActivity(actionType, actionCount); const activity = parseActivity(actionType, actionCount);
const frequency =
sys.frequency?.max != null && sys.frequency.per
? `${sys.frequency.max}/${sys.frequency.per}`
: undefined;
if (frequency) {
description = stripFrequencyLine(description);
}
const traitStr = const traitStr =
traits.length > 0 traits.length > 0
? `(${traits.map((t) => capitalize(formatTrait(t))).join(", ")}) ` ? `(${traits.map((t) => capitalize(formatTrait(t))).join(", ")}) `
@@ -434,7 +401,7 @@ function normalizeAbility(item: RawFoundryItem): TraitBlock {
? [{ type: "text", value: text }] ? [{ type: "text", value: text }]
: []; : [];
return { name: item.name, activity, frequency, segments }; return { name: item.name, activity, segments };
} }
// -- Spellcasting normalization -- // -- Spellcasting normalization --
@@ -503,25 +470,16 @@ function formatOverlays(overlays: SpellSystem["overlays"]): string | undefined {
*/ */
const HEIGHTENED_SUFFIX = /\s*Heightened\s*\([^)]*\)[\s\S]*$/; const HEIGHTENED_SUFFIX = /\s*Heightened\s*\([^)]*\)[\s\S]*$/;
function normalizeSpell( function normalizeSpell(item: RawFoundryItem): SpellReference {
item: RawFoundryItem,
creatureLevel: number,
): SpellReference {
const sys = item.system as unknown as SpellSystem; const sys = item.system as unknown as SpellSystem;
const usesMax = sys.location?.uses?.max; const usesMax = sys.location?.uses?.max;
const isCantrip = sys.traits?.value?.includes("cantrip") ?? false; const rank = sys.location?.heightenedLevel ?? sys.level?.value ?? 0;
const rank =
sys.location?.heightenedLevel ??
(isCantrip ? Math.ceil(creatureLevel / 2) : (sys.level?.value ?? 0));
const heightening = const heightening =
formatHeightening(sys.heightening) ?? formatOverlays(sys.overlays); formatHeightening(sys.heightening) ?? formatOverlays(sys.overlays);
let description: string | undefined; let description: string | undefined;
if (sys.description?.value) { if (sys.description?.value) {
let text = stripFoundryTags(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) { if (heightening) {
text = text.replace(HEIGHTENED_SUFFIX, "").trim(); text = text.replace(HEIGHTENED_SUFFIX, "").trim();
} }
@@ -549,7 +507,6 @@ function normalizeSpell(
function normalizeSpellcastingEntry( function normalizeSpellcastingEntry(
entry: RawFoundryItem, entry: RawFoundryItem,
allSpells: readonly RawFoundryItem[], allSpells: readonly RawFoundryItem[],
creatureLevel: number,
): SpellcastingBlock { ): SpellcastingBlock {
const sys = entry.system as unknown as SpellcastingEntrySystem; const sys = entry.system as unknown as SpellcastingEntrySystem;
const tradition = capitalize(sys.tradition?.value ?? ""); const tradition = capitalize(sys.tradition?.value ?? "");
@@ -568,7 +525,7 @@ function normalizeSpellcastingEntry(
const cantrips: SpellReference[] = []; const cantrips: SpellReference[] = [];
for (const spell of linkedSpells) { for (const spell of linkedSpells) {
const ref = normalizeSpell(spell, creatureLevel); const ref = normalizeSpell(spell);
const isCantrip = const isCantrip =
(spell.system as unknown as SpellSystem).traits?.value?.includes( (spell.system as unknown as SpellSystem).traits?.value?.includes(
"cantrip", "cantrip",
@@ -601,13 +558,10 @@ function normalizeSpellcastingEntry(
function normalizeSpellcasting( function normalizeSpellcasting(
items: readonly RawFoundryItem[], items: readonly RawFoundryItem[],
creatureLevel: number,
): SpellcastingBlock[] { ): SpellcastingBlock[] {
const entries = items.filter((i) => i.type === "spellcastingEntry"); const entries = items.filter((i) => i.type === "spellcastingEntry");
const spells = items.filter((i) => i.type === "spell"); const spells = items.filter((i) => i.type === "spell");
return entries.map((entry) => return entries.map((entry) => normalizeSpellcastingEntry(entry, spells));
normalizeSpellcastingEntry(entry, spells, creatureLevel),
);
} }
// -- Main normalization -- // -- Main normalization --
@@ -717,7 +671,6 @@ export function normalizeFoundryCreature(
level: sys.details?.level?.value ?? 0, level: sys.details?.level?.value ?? 0,
traits: buildTraits(sys.traits), traits: buildTraits(sys.traits),
perception: sys.perception?.mod ?? 0, perception: sys.perception?.mod ?? 0,
perceptionDetails: sys.perception?.details || undefined,
senses: formatSenses(sys.perception?.senses), senses: formatSenses(sys.perception?.senses),
languages: formatLanguages(sys.details?.languages), languages: formatLanguages(sys.details?.languages),
skills: formatSkills(sys.skills), skills: formatSkills(sys.skills),
@@ -735,9 +688,7 @@ export function normalizeFoundryCreature(
weaknesses: formatWeaknesses(sys.attributes.weaknesses), weaknesses: formatWeaknesses(sys.attributes.weaknesses),
speed: formatSpeed(sys.attributes.speed), speed: formatSpeed(sys.attributes.speed),
attacks: orUndefined( attacks: orUndefined(
items items.filter((i) => i.type === "melee").map(normalizeAttack),
.filter((i) => i.type === "melee")
.map((i) => normalizeAttack(i, r.name)),
), ),
abilitiesTop: orUndefined(actionsByCategory(items, "interaction")), abilitiesTop: orUndefined(actionsByCategory(items, "interaction")),
abilitiesMid: orUndefined( abilitiesMid: orUndefined(
@@ -749,9 +700,7 @@ export function normalizeFoundryCreature(
), ),
), ),
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")), abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
spellcasting: orUndefined( spellcasting: orUndefined(normalizeSpellcasting(items)),
normalizeSpellcasting(items, sys.details?.level?.value ?? 0),
),
items: items:
items items
.filter(isMundaneItem) .filter(isMundaneItem)
@@ -69,18 +69,6 @@ export function getCreaturePathsForSource(sourceCode: string): string[] {
return compact.creatures.filter((c) => c.s === sourceCode).map((c) => c.f); return compact.creatures.filter((c) => c.s === sourceCode).map((c) => c.f);
} }
export function getCreatureNamesByPaths(paths: string[]): Map<string, string> {
const compact = rawIndex as unknown as CompactIndex;
const pathSet = new Set(paths);
const result = new Map<string, string>();
for (const c of compact.creatures) {
if (pathSet.has(c.f)) {
result.set(c.f, c.n);
}
}
return result;
}
export function getPf2eSourceDisplayName(sourceCode: string): string { export function getPf2eSourceDisplayName(sourceCode: string): string {
const index = loadPf2eBestiaryIndex(); const index = loadPf2eBestiaryIndex();
return index.sources[sourceCode] ?? sourceCode; return index.sources[sourceCode] ?? sourceCode;
+22 -26
View File
@@ -9,18 +9,18 @@ import type {
} from "@initiative/domain"; } from "@initiative/domain";
export interface EncounterPersistence { export interface EncounterPersistence {
readonly load: () => Encounter | null; load(): Encounter | null;
readonly save: (encounter: Encounter) => void; save(encounter: Encounter): void;
} }
export interface UndoRedoPersistence { export interface UndoRedoPersistence {
readonly load: () => UndoRedoState; load(): UndoRedoState;
readonly save: (state: UndoRedoState) => void; save(state: UndoRedoState): void;
} }
export interface PlayerCharacterPersistence { export interface PlayerCharacterPersistence {
readonly load: () => PlayerCharacter[]; load(): PlayerCharacter[];
readonly save: (characters: PlayerCharacter[]) => void; save(characters: PlayerCharacter[]): void;
} }
export interface CachedSourceInfo { export interface CachedSourceInfo {
@@ -31,34 +31,30 @@ export interface CachedSourceInfo {
} }
export interface BestiaryCachePort { export interface BestiaryCachePort {
readonly cacheSource: ( cacheSource(
system: string, system: string,
sourceCode: string, sourceCode: string,
displayName: string, displayName: string,
creatures: AnyCreature[], creatures: AnyCreature[],
) => Promise<void>; ): Promise<void>;
readonly isSourceCached: ( isSourceCached(system: string, sourceCode: string): Promise<boolean>;
system: string, getCachedSources(system?: string): Promise<CachedSourceInfo[]>;
sourceCode: string, clearSource(system: string, sourceCode: string): Promise<void>;
) => Promise<boolean>; clearAll(): Promise<void>;
readonly getCachedSources: (system?: string) => Promise<CachedSourceInfo[]>; loadAllCachedCreatures(): Promise<Map<CreatureId, AnyCreature>>;
readonly clearSource: (system: string, sourceCode: string) => Promise<void>;
readonly clearAll: () => Promise<void>;
readonly loadAllCachedCreatures: () => Promise<Map<CreatureId, AnyCreature>>;
} }
export interface BestiaryIndexPort { export interface BestiaryIndexPort {
readonly loadIndex: () => BestiaryIndex; loadIndex(): BestiaryIndex;
readonly getAllSourceCodes: () => string[]; getAllSourceCodes(): string[];
readonly getDefaultFetchUrl: (sourceCode: string, baseUrl?: string) => string; getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
readonly getSourceDisplayName: (sourceCode: string) => string; getSourceDisplayName(sourceCode: string): string;
} }
export interface Pf2eBestiaryIndexPort { export interface Pf2eBestiaryIndexPort {
readonly loadIndex: () => Pf2eBestiaryIndex; loadIndex(): Pf2eBestiaryIndex;
readonly getAllSourceCodes: () => string[]; getAllSourceCodes(): string[];
readonly getDefaultFetchUrl: (sourceCode: string, baseUrl?: string) => string; getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
readonly getSourceDisplayName: (sourceCode: string) => string; getSourceDisplayName(sourceCode: string): string;
readonly getCreaturePathsForSource: (sourceCode: string) => string[]; getCreaturePathsForSource(sourceCode: string): string[];
readonly getCreatureNamesByPaths: (paths: string[]) => Map<string, string>;
} }
@@ -48,6 +48,5 @@ export const productionAdapters: Adapters = {
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl, getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName, getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
getCreaturePathsForSource: pf2eBestiaryIndex.getCreaturePathsForSource, getCreaturePathsForSource: pf2eBestiaryIndex.getCreaturePathsForSource,
getCreatureNamesByPaths: pf2eBestiaryIndex.getCreatureNamesByPaths,
}, },
}; };
+3 -14
View File
@@ -8,13 +8,7 @@
function formatDamage(params: string): string { function formatDamage(params: string): string {
// "3d6+10[fire]" → "3d6+10 fire" // "3d6+10[fire]" → "3d6+10 fire"
// "d4[persistent,fire]" → "d4 persistent fire" return params.replaceAll(/\[([^\]]*)\]/g, " $1").trim();
return params
.replaceAll(
/\[([^\]]*)\]/g,
(_, type: string) => ` ${type.replaceAll(",", " ")}`,
)
.trim();
} }
function formatCheck(params: string): string { function formatCheck(params: string): string {
@@ -86,11 +80,11 @@ export function stripFoundryTags(html: string): string {
// Strip action-glyph spans (content is a number the renderer handles) // Strip action-glyph spans (content is a number the renderer handles)
result = result.replaceAll(/<span class="action-glyph">[^<]*<\/span>/gi, ""); result = result.replaceAll(/<span class="action-glyph">[^<]*<\/span>/gi, "");
// Strip HTML tags (preserve <strong> for UI rendering) // Strip HTML tags
result = result.replaceAll(/<br\s*\/?>/gi, "\n"); result = result.replaceAll(/<br\s*\/?>/gi, "\n");
result = result.replaceAll(/<hr\s*\/?>/gi, "\n"); result = result.replaceAll(/<hr\s*\/?>/gi, "\n");
result = result.replaceAll(/<\/p>\s*<p[^>]*>/gi, "\n"); result = result.replaceAll(/<\/p>\s*<p[^>]*>/gi, "\n");
result = result.replaceAll(/<(?!\/?(?:strong|em|ul|ol|li)\b)[^>]+>/g, ""); result = result.replaceAll(/<[^>]+>/g, "");
// Decode common HTML entities // Decode common HTML entities
result = result.replaceAll("&amp;", "&"); result = result.replaceAll("&amp;", "&");
@@ -98,11 +92,6 @@ export function stripFoundryTags(html: string): string {
result = result.replaceAll("&gt;", ">"); result = result.replaceAll("&gt;", ">");
result = result.replaceAll("&quot;", '"'); result = result.replaceAll("&quot;", '"');
// Collapse whitespace around list tags so they don't create extra
// line breaks when rendered with whitespace-pre-line
result = result.replaceAll(/\s*(<\/?(?:ul|ol)>)\s*/g, "$1");
result = result.replaceAll(/\s*(<\/?li>)\s*/g, "$1");
// Collapse whitespace // Collapse whitespace
result = result.replaceAll(/[ \t]+/g, " "); result = result.replaceAll(/[ \t]+/g, " ");
result = result.replaceAll(/\n\s*\n/g, "\n"); result = result.replaceAll(/\n\s*\n/g, "\n");
@@ -373,26 +373,6 @@ describe("CombatantRow", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("popover is not dimmed when the combatant is downed", async () => {
const user = userEvent.setup();
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 10,
currentHp: 0,
},
});
await user.click(screen.getByLabelText(CURRENT_HP_REGEX));
// The row dims downed combatants with opacity-50, which would cascade
// to the popover if it rendered inside the dimmed subtree.
const popover = screen
.getByRole("button", { name: "Apply damage" })
.closest(".opacity-50");
expect(popover).toBeNull();
});
it("HP section is absent when maxHp is undefined", () => { it("HP section is absent when maxHp is undefined", () => {
renderRow({ renderRow({
combatant: { combatant: {
@@ -5,73 +5,43 @@ import {
type ConditionEntry, type ConditionEntry,
type ConditionId, type ConditionId,
getConditionsForEdition, getConditionsForEdition,
type PersistentDamageEntry,
type PersistentDamageType,
type RulesEdition,
} from "@initiative/domain"; } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { createRef, type ReactNode, type RefObject, useEffect } from "react"; import { createRef, type RefObject } from "react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { RulesEditionProvider } from "../../contexts/index.js"; import { RulesEditionProvider } from "../../contexts/index.js";
import { useRulesEditionContext } from "../../contexts/rules-edition-context.js";
import { ConditionPicker } from "../condition-picker"; import { ConditionPicker } from "../condition-picker";
afterEach(cleanup); afterEach(cleanup);
function EditionSetter({
edition,
children,
}: {
edition: RulesEdition;
children: ReactNode;
}) {
const { setEdition } = useRulesEditionContext();
useEffect(() => {
setEdition(edition);
}, [edition, setEdition]);
return <>{children}</>;
}
function renderPicker( function renderPicker(
overrides: Partial<{ overrides: Partial<{
activeConditions: readonly ConditionEntry[]; activeConditions: readonly ConditionEntry[];
activePersistentDamage: readonly PersistentDamageEntry[];
onToggle: (conditionId: ConditionId) => void; onToggle: (conditionId: ConditionId) => void;
onSetValue: (conditionId: ConditionId, value: number) => void; onSetValue: (conditionId: ConditionId, value: number) => void;
onAddPersistentDamage: (
damageType: PersistentDamageType,
formula: string,
) => void;
onClose: () => void; onClose: () => void;
edition: RulesEdition;
}> = {}, }> = {},
) { ) {
const onToggle = overrides.onToggle ?? vi.fn(); const onToggle = overrides.onToggle ?? vi.fn();
const onSetValue = overrides.onSetValue ?? vi.fn(); const onSetValue = overrides.onSetValue ?? vi.fn();
const onAddPersistentDamage = overrides.onAddPersistentDamage ?? vi.fn();
const onClose = overrides.onClose ?? vi.fn(); const onClose = overrides.onClose ?? vi.fn();
const edition = overrides.edition ?? "5.5e";
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>; const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
const anchor = document.createElement("div"); const anchor = document.createElement("div");
document.body.appendChild(anchor); document.body.appendChild(anchor);
(anchorRef as { current: HTMLElement }).current = anchor; (anchorRef as { current: HTMLElement }).current = anchor;
const result = render( const result = render(
<RulesEditionProvider> <RulesEditionProvider>
<EditionSetter edition={edition}>
<ConditionPicker <ConditionPicker
anchorRef={anchorRef} anchorRef={anchorRef}
activeConditions={overrides.activeConditions ?? []} activeConditions={overrides.activeConditions ?? []}
activePersistentDamage={overrides.activePersistentDamage}
onToggle={onToggle} onToggle={onToggle}
onSetValue={onSetValue} onSetValue={onSetValue}
onAddPersistentDamage={onAddPersistentDamage}
onClose={onClose} onClose={onClose}
/> />
</EditionSetter>
</RulesEditionProvider>, </RulesEditionProvider>,
); );
return { ...result, onToggle, onSetValue, onAddPersistentDamage, onClose }; return { ...result, onToggle, onSetValue, onClose };
} }
describe("ConditionPicker", () => { describe("ConditionPicker", () => {
@@ -107,111 +77,4 @@ describe("ConditionPicker", () => {
const label = screen.getByText("Charmed"); const label = screen.getByText("Charmed");
expect(label.className).toContain("text-foreground"); expect(label.className).toContain("text-foreground");
}); });
describe("Valued conditions (PF2e)", () => {
it("clicking a valued condition opens the counter editor", async () => {
const user = userEvent.setup();
renderPicker({ edition: "pf2e" });
await user.click(screen.getByText("Frightened"));
// Counter editor shows value badge and [-]/[+] buttons
expect(screen.getByText("1")).toBeInTheDocument();
expect(
screen
.getAllByRole("button")
.some((b) => b.querySelector(".lucide-minus")),
).toBe(true);
});
it("increment and decrement adjust the counter value", async () => {
const user = userEvent.setup();
renderPicker({ edition: "pf2e" });
await user.click(screen.getByText("Frightened"));
// Value starts at 1; click [+] to go to 2
const plusButtons = screen.getAllByRole("button");
const plusButton = plusButtons.find((b) =>
b.querySelector(".lucide-plus"),
);
if (!plusButton) throw new Error("Plus button not found");
await user.click(plusButton);
expect(screen.getByText("2")).toBeInTheDocument();
// Click [-] to go back to 1
const minusButton = plusButtons.find((b) =>
b.querySelector(".lucide-minus"),
);
if (!minusButton) throw new Error("Minus button not found");
await user.click(minusButton);
expect(screen.getByText("1")).toBeInTheDocument();
});
it("confirm button calls onSetValue with condition and value", async () => {
const user = userEvent.setup();
const { onSetValue } = renderPicker({ edition: "pf2e" });
await user.click(screen.getByText("Frightened"));
// Increment to 2, then confirm
const plusButton = screen
.getAllByRole("button")
.find((b) => b.querySelector(".lucide-plus"));
if (!plusButton) throw new Error("Plus button not found");
await user.click(plusButton);
const checkButton = screen
.getAllByRole("button")
.find((b) => b.querySelector(".lucide-check"));
if (!checkButton) throw new Error("Check button not found");
await user.click(checkButton);
expect(onSetValue).toHaveBeenCalledWith("frightened", 2);
});
it("shows active value badge for existing valued condition", () => {
renderPicker({
edition: "pf2e",
activeConditions: [{ id: "frightened", value: 3 }],
});
expect(screen.getByText("3")).toBeInTheDocument();
});
it("pre-fills counter with existing value when editing", async () => {
const user = userEvent.setup();
renderPicker({
edition: "pf2e",
activeConditions: [{ id: "frightened", value: 3 }],
});
await user.click(screen.getByText("Frightened"));
expect(screen.getByText("3")).toBeInTheDocument();
});
it("disables increment at maxValue", async () => {
const user = userEvent.setup();
renderPicker({
edition: "pf2e",
activeConditions: [{ id: "doomed", value: 3 }],
});
// Doomed has maxValue: 3, click to edit
await user.click(screen.getByText("Doomed"));
const plusButton = screen
.getAllByRole("button")
.find((b) => b.querySelector(".lucide-plus"));
expect(plusButton).toBeDisabled();
});
});
describe("Persistent Damage (PF2e)", () => {
it("shows 'Persistent Damage' entry when edition is pf2e", () => {
renderPicker({ edition: "pf2e" });
expect(screen.getByText("Persistent Damage")).toBeInTheDocument();
});
it("clicking 'Persistent Damage' opens sub-picker", async () => {
const user = userEvent.setup();
renderPicker({ edition: "pf2e" });
await user.click(screen.getByText("Persistent Damage"));
expect(screen.getByPlaceholderText("2d6")).toBeInTheDocument();
});
});
describe("Persistent Damage (D&D)", () => {
it("hides 'Persistent Damage' entry when edition is D&D", () => {
renderPicker({ edition: "5.5e" });
expect(screen.queryByText("Persistent Damage")).not.toBeInTheDocument();
});
});
}); });
@@ -38,22 +38,6 @@ describe("Dialog", () => {
expect(dialog?.hasAttribute("open")).toBe(false); expect(dialog?.hasAttribute("open")).toBe(false);
}); });
it("unmounts children when closed so internal state does not persist", () => {
const { rerender } = render(
<Dialog open={true} onClose={() => {}}>
<span>Body</span>
</Dialog>,
);
expect(screen.queryByText("Body")).not.toBeNull();
rerender(
<Dialog open={false} onClose={() => {}}>
<span>Body</span>
</Dialog>,
);
expect(screen.queryByText("Body")).toBeNull();
});
it("calls onClose on cancel event", () => { it("calls onClose on cancel event", () => {
const onClose = vi.fn(); const onClose = vi.fn();
render( render(
@@ -1,11 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import type { import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
AnyCreature,
CreatureId,
PlayerCharacter,
} from "@initiative/domain";
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
import { import {
cleanup, cleanup,
@@ -21,7 +17,6 @@ import {
buildCombatant, buildCombatant,
buildCreature, buildCreature,
buildEncounter, buildEncounter,
buildPf2eCreature,
} from "../../__tests__/factories/index.js"; } from "../../__tests__/factories/index.js";
import { AllProviders } from "../../__tests__/test-providers.js"; import { AllProviders } from "../../__tests__/test-providers.js";
import { useRulesEdition } from "../../hooks/use-rules-edition.js"; import { useRulesEdition } from "../../hooks/use-rules-edition.js";
@@ -57,7 +52,7 @@ const goblinCreature = buildCreature({
function renderPanel(options: { function renderPanel(options: {
encounter: ReturnType<typeof buildEncounter>; encounter: ReturnType<typeof buildEncounter>;
playerCharacters?: PlayerCharacter[]; playerCharacters?: PlayerCharacter[];
creatures?: Map<CreatureId, AnyCreature>; creatures?: Map<CreatureId, Creature>;
onClose?: () => void; onClose?: () => void;
}) { }) {
const adapters = createTestAdapters({ const adapters = createTestAdapters({
@@ -362,157 +357,4 @@ describe("DifficultyBreakdownPanel", () => {
expect(onClose).toHaveBeenCalledOnce(); expect(onClose).toHaveBeenCalledOnce();
}); });
describe("PF2e edition", () => {
const orcWarrior = buildPf2eCreature({
id: creatureId("pf2e:orc-warrior"),
name: "Orc Warrior",
level: 3,
source: "crb",
sourceDisplayName: "Core Rulebook",
});
function pf2eEncounter() {
return buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c-1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c-2"),
name: "Orc Warrior",
creatureId: orcWarrior.id,
}),
],
});
}
it("shows PF2e tier label", async () => {
const { result: editionResult } = renderHook(() => useRulesEdition());
editionResult.current.setEdition("pf2e");
try {
renderPanel({
encounter: pf2eEncounter(),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[orcWarrior.id, orcWarrior]]),
});
await waitFor(() => {
expect(
screen.getByText("Encounter Difficulty:", { exact: false }),
).toBeInTheDocument();
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
it("shows party level", async () => {
const { result: editionResult } = renderHook(() => useRulesEdition());
editionResult.current.setEdition("pf2e");
try {
renderPanel({
encounter: pf2eEncounter(),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[orcWarrior.id, orcWarrior]]),
});
await waitFor(() => {
expect(
screen.getByText("Party Level: 5", { exact: false }),
).toBeInTheDocument();
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
it("shows creature level and level difference", async () => {
const { result: editionResult } = renderHook(() => useRulesEdition());
editionResult.current.setEdition("pf2e");
try {
renderPanel({
encounter: pf2eEncounter(),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[orcWarrior.id, orcWarrior]]),
});
await waitFor(() => {
// Orc Warrior level 3, party level 5 → diff 2
expect(
screen.getByText("Lv 3 (-2)", { exact: false }),
).toBeInTheDocument();
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
it("shows 5 thresholds with short labels", async () => {
const { result: editionResult } = renderHook(() => useRulesEdition());
editionResult.current.setEdition("pf2e");
try {
renderPanel({
encounter: pf2eEncounter(),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[orcWarrior.id, orcWarrior]]),
});
await waitFor(() => {
expect(
screen.getByText("Triv:", { exact: false }),
).toBeInTheDocument();
expect(
screen.getByText("Low:", { exact: false }),
).toBeInTheDocument();
expect(
screen.getByText("Mod:", { exact: false }),
).toBeInTheDocument();
expect(
screen.getByText("Sev:", { exact: false }),
).toBeInTheDocument();
expect(
screen.getByText("Ext:", { exact: false }),
).toBeInTheDocument();
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
it("shows Net Creature XP label in PF2e mode", async () => {
const { result: editionResult } = renderHook(() => useRulesEdition());
editionResult.current.setEdition("pf2e");
try {
renderPanel({
encounter: pf2eEncounter(),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[orcWarrior.id, orcWarrior]]),
});
await waitFor(() => {
expect(screen.getByText("Net Creature XP")).toBeInTheDocument();
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
});
}); });
@@ -7,7 +7,6 @@ import {
DifficultyIndicator, DifficultyIndicator,
TIER_LABELS_5_5E, TIER_LABELS_5_5E,
TIER_LABELS_2014, TIER_LABELS_2014,
TIER_LABELS_PF2E,
} from "../difficulty-indicator.js"; } from "../difficulty-indicator.js";
afterEach(cleanup); afterEach(cleanup);
@@ -24,7 +23,6 @@ function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
encounterMultiplier: undefined, encounterMultiplier: undefined,
adjustedXp: undefined, adjustedXp: undefined,
partySizeAdjusted: undefined, partySizeAdjusted: undefined,
partyLevel: undefined,
}; };
} }
@@ -127,64 +125,4 @@ describe("DifficultyIndicator", () => {
const element = container.querySelector("[role='img']"); const element = container.querySelector("[role='img']");
expect(element?.tagName).toBe("BUTTON"); expect(element?.tagName).toBe("BUTTON");
}); });
it("renders 4 bars when barCount is 4", () => {
const { container } = render(
<DifficultyIndicator
result={makeResult(2)}
labels={TIER_LABELS_PF2E}
barCount={4}
/>,
);
const bars = container.querySelectorAll("[class*='rounded-sm']");
expect(bars).toHaveLength(4);
});
it("shows 0 filled bars for tier 0 with 4 bars", () => {
const { container } = render(
<DifficultyIndicator
result={makeResult(0)}
labels={TIER_LABELS_PF2E}
barCount={4}
/>,
);
const bars = container.querySelectorAll("[class*='rounded-sm']");
for (const bar of bars) {
expect(bar.className).toContain("bg-muted");
}
});
it("shows correct PF2e tooltip for Severe tier", () => {
render(
<DifficultyIndicator
result={makeResult(3)}
labels={TIER_LABELS_PF2E}
barCount={4}
/>,
);
expect(
screen.getByRole("img", { name: "Severe encounter difficulty" }),
).toBeDefined();
});
it("shows correct PF2e tooltip for Extreme tier", () => {
render(
<DifficultyIndicator
result={makeResult(4)}
labels={TIER_LABELS_PF2E}
barCount={4}
/>,
);
expect(
screen.getByRole("img", { name: "Extreme encounter difficulty" }),
).toBeDefined();
});
it("D&D indicator still renders 3 bars (no regression)", () => {
const { container } = render(
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_5_5E} />,
);
const bars = container.querySelectorAll("[class*='rounded-sm']");
expect(bars).toHaveLength(3);
});
}); });
@@ -3,41 +3,11 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { useEffect, useRef, useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { HpAdjustPopover } from "../hp-adjust-popover"; import { HpAdjustPopover } from "../hp-adjust-popover";
afterEach(cleanup); afterEach(cleanup);
function AnchoredPopover({
onAdjust,
onSetTempHp,
onClose,
}: Readonly<{
onAdjust: (delta: number) => void;
onSetTempHp: (value: number) => void;
onClose: () => void;
}>) {
const anchorRef = useRef<HTMLDivElement>(null);
// The popover opens on click in the app, so its anchor is always mounted
// first. Mirror that here — otherwise the anchor ref is still null when the
// popover measures its position and it renders hidden.
const [open, setOpen] = useState(false);
useEffect(() => setOpen(true), []);
return (
<div ref={anchorRef}>
{!!open && (
<HpAdjustPopover
anchorRef={anchorRef}
onAdjust={onAdjust}
onSetTempHp={onSetTempHp}
onClose={onClose}
/>
)}
</div>
);
}
function renderPopover( function renderPopover(
overrides: Partial<{ overrides: Partial<{
onAdjust: (delta: number) => void; onAdjust: (delta: number) => void;
@@ -49,7 +19,7 @@ function renderPopover(
const onSetTempHp = overrides.onSetTempHp ?? vi.fn(); const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
const onClose = overrides.onClose ?? vi.fn(); const onClose = overrides.onClose ?? vi.fn();
const result = render( const result = render(
<AnchoredPopover <HpAdjustPopover
onAdjust={onAdjust} onAdjust={onAdjust}
onSetTempHp={onSetTempHp} onSetTempHp={onSetTempHp}
onClose={onClose} onClose={onClose}
@@ -1,76 +0,0 @@
// @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("");
});
});
@@ -1,66 +0,0 @@
// @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();
});
});
@@ -63,11 +63,8 @@ describe("SettingsModal", () => {
renderModal(); renderModal();
const btn5e = screen.getByRole("button", { name: "5e (2014)" }); const btn5e = screen.getByRole("button", { name: "5e (2014)" });
await user.click(btn5e); await user.click(btn5e);
expect(btn5e).toHaveAttribute("aria-pressed", "true"); // After clicking 5e, it should have the active style
expect(screen.getByRole("button", { name: "5.5e (2024)" })).toHaveAttribute( expect(btn5e.className).toContain("bg-accent");
"aria-pressed",
"false",
);
}); });
it("clicking a theme button switches the active theme", async () => { it("clicking a theme button switches the active theme", async () => {
@@ -75,7 +72,7 @@ describe("SettingsModal", () => {
renderModal(); renderModal();
const darkBtn = screen.getByRole("button", { name: "Dark" }); const darkBtn = screen.getByRole("button", { name: "Dark" });
await user.click(darkBtn); await user.click(darkBtn);
expect(darkBtn).toHaveAttribute("aria-pressed", "true"); expect(darkBtn.className).toContain("bg-accent");
}); });
it("close button calls onClose", async () => { it("close button calls onClose", async () => {
@@ -65,7 +65,7 @@ describe("SourceFetchPrompt", () => {
}); });
it("Load calls fetchAndCacheSource and onSourceLoaded on success", async () => { it("Load calls fetchAndCacheSource and onSourceLoaded on success", async () => {
mockFetchAndCacheSource.mockResolvedValueOnce({ skippedNames: [] }); mockFetchAndCacheSource.mockResolvedValueOnce(undefined);
const user = userEvent.setup(); const user = userEvent.setup();
const { onSourceLoaded } = renderPrompt(); const { onSourceLoaded } = renderPrompt();
@@ -1,11 +1,9 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import type { Creature, HpVariant } from "@initiative/domain"; import type { Creature } from "@initiative/domain";
import { combatantId, creatureId } from "@initiative/domain"; import { creatureId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { DndStatBlock as StatBlock } from "../dnd-stat-block.js"; import { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
@@ -130,19 +128,6 @@ function renderStatBlock(creature: Creature) {
return render(<StatBlock creature={creature} />); return render(<StatBlock creature={creature} />);
} }
/** Owns the variant the way the encounter state does in the real app. */
function HpVariantHarness({ creature }: Readonly<{ creature: Creature }>) {
const [variant, setVariant] = useState<HpVariant | undefined>(undefined);
return (
<StatBlock
creature={creature}
combatantId={combatantId("c-1")}
hpVariant={variant}
onSetHpVariant={(_id, next) => setVariant(next)}
/>
);
}
describe("StatBlock", () => { describe("StatBlock", () => {
describe("header", () => { describe("header", () => {
it("renders creature name", () => { it("renders creature name", () => {
@@ -190,81 +175,6 @@ describe("StatBlock", () => {
}); });
}); });
describe("hit point variant", () => {
it("offers no variant buttons while browsing without a combatant", () => {
renderStatBlock(GOBLIN);
expect(
screen.queryByRole("button", { name: "Max" }),
).not.toBeInTheDocument();
});
it("hides the variant buttons when the HP formula is not a dice pool", () => {
render(
<HpVariantHarness
creature={{
...GOBLIN,
hp: { average: 50, formula: "special" },
}}
/>,
);
expect(
screen.queryByRole("button", { name: "Max" }),
).not.toBeInTheDocument();
expect(screen.getByText("50")).toBeInTheDocument();
});
it("shows the printed average until a variant is picked", () => {
render(<HpVariantHarness creature={GOBLIN} />);
expect(screen.getByText("7")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Avg" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
it("shows the maximum roll after picking Max", async () => {
const user = userEvent.setup();
render(<HpVariantHarness creature={GOBLIN} />);
await user.click(screen.getByRole("button", { name: "Max" }));
expect(screen.getByText("12")).toBeInTheDocument();
expect(screen.queryByText("7")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Max" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
it("shows the minimum roll after picking Min", async () => {
const user = userEvent.setup();
render(<HpVariantHarness creature={GOBLIN} />);
await user.click(screen.getByRole("button", { name: "Min" }));
expect(screen.getByText("2")).toBeInTheDocument();
});
it("returns to the average after picking Avg again", async () => {
const user = userEvent.setup();
render(<HpVariantHarness creature={GOBLIN} />);
await user.click(screen.getByRole("button", { name: "Max" }));
await user.click(screen.getByRole("button", { name: "Avg" }));
expect(screen.getByText("7")).toBeInTheDocument();
});
it("keeps showing the formula for every variant", async () => {
const user = userEvent.setup();
render(<HpVariantHarness creature={GOBLIN} />);
await user.click(screen.getByRole("button", { name: "Min" }));
expect(screen.getByText("(2d6)")).toBeInTheDocument();
});
});
describe("ability scores", () => { describe("ability scores", () => {
it("renders all 6 ability labels", () => { it("renders all 6 ability labels", () => {
renderStatBlock(GOBLIN); renderStatBlock(GOBLIN);
+5 -31
View File
@@ -3,7 +3,6 @@ import {
type ConditionEntry, type ConditionEntry,
type CreatureId, type CreatureId,
deriveHpStatus, deriveHpStatus,
type PersistentDamageEntry,
type PlayerIcon, type PlayerIcon,
type RollMode, type RollMode,
} from "@initiative/domain"; } from "@initiative/domain";
@@ -20,7 +19,6 @@ import { ConditionPicker } from "./condition-picker.js";
import { ConditionTags } from "./condition-tags.js"; import { ConditionTags } from "./condition-tags.js";
import { D20Icon } from "./d20-icon.js"; import { D20Icon } from "./d20-icon.js";
import { HpAdjustPopover } from "./hp-adjust-popover.js"; import { HpAdjustPopover } from "./hp-adjust-popover.js";
import { PersistentDamageTags } from "./persistent-damage-tags.js";
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
import { RollModeMenu } from "./roll-mode-menu.js"; import { RollModeMenu } from "./roll-mode-menu.js";
import { ConfirmButton } from "./ui/confirm-button.js"; import { ConfirmButton } from "./ui/confirm-button.js";
@@ -35,7 +33,6 @@ interface Combatant {
readonly tempHp?: number; readonly tempHp?: number;
readonly ac?: number; readonly ac?: number;
readonly conditions?: readonly ConditionEntry[]; readonly conditions?: readonly ConditionEntry[];
readonly persistentDamage?: readonly PersistentDamageEntry[];
readonly isConcentrating?: boolean; readonly isConcentrating?: boolean;
readonly color?: string; readonly color?: string;
readonly icon?: string; readonly icon?: string;
@@ -202,7 +199,6 @@ function ClickableHp({
onSetTempHp: (value: number) => void; onSetTempHp: (value: number) => void;
}>) { }>) {
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
const status = deriveHpStatus(currentHp, maxHp); const status = deriveHpStatus(currentHp, maxHp);
if (maxHp === undefined) { if (maxHp === undefined) {
@@ -210,7 +206,7 @@ function ClickableHp({
} }
return ( return (
<div ref={anchorRef} className="relative flex items-center"> <div className="relative flex items-center">
<button <button
type="button" type="button"
onClick={() => setPopoverOpen(true)} onClick={() => setPopoverOpen(true)}
@@ -231,7 +227,6 @@ function ClickableHp({
)} )}
{!!popoverOpen && ( {!!popoverOpen && (
<HpAdjustPopover <HpAdjustPopover
anchorRef={anchorRef}
onAdjust={onAdjust} onAdjust={onAdjust}
onSetTempHp={onSetTempHp} onSetTempHp={onSetTempHp}
onClose={() => setPopoverOpen(false)} onClose={() => setPopoverOpen(false)}
@@ -459,23 +454,15 @@ export function CombatantRow({
setConditionValue, setConditionValue,
decrementCondition, decrementCondition,
toggleConcentration, toggleConcentration,
addPersistentDamage,
removePersistentDamage,
} = useEncounterContext(); } = useEncounterContext();
const { const { selectedCreatureId, showCreature, toggleCollapse } =
selectedCreatureId, useSidePanelContext();
selectedCombatantId,
showCreature,
toggleCollapse,
} = useSidePanelContext();
const { handleRollInitiative } = useInitiativeRollsContext(); const { handleRollInitiative } = useInitiativeRollsContext();
const { edition } = useRulesEditionContext(); const { edition } = useRulesEditionContext();
const isPf2e = edition === "pf2e"; const isPf2e = edition === "pf2e";
// Derive what was previously conditional props // Derive what was previously conditional props
const isStatBlockOpen = const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
combatant.creatureId === selectedCreatureId &&
combatant.id === selectedCombatantId;
const { creatureId } = combatant; const { creatureId } = combatant;
const hasStatBlock = !!creatureId; const hasStatBlock = !!creatureId;
const onToggleStatBlock = hasStatBlock const onToggleStatBlock = hasStatBlock
@@ -483,7 +470,7 @@ export function CombatantRow({
if (isStatBlockOpen) { if (isStatBlockOpen) {
toggleCollapse(); toggleCollapse();
} else { } else {
showCreature(creatureId, combatant.id); showCreature(creatureId);
} }
} }
: undefined; : undefined;
@@ -620,29 +607,16 @@ export function CombatantRow({
onRemove={(conditionId) => toggleCondition(id, conditionId)} onRemove={(conditionId) => toggleCondition(id, conditionId)}
onDecrement={(conditionId) => decrementCondition(id, conditionId)} onDecrement={(conditionId) => decrementCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)} onOpenPicker={() => setPickerOpen((prev) => !prev)}
>
{isPf2e && (
<PersistentDamageTags
entries={combatant.persistentDamage}
onRemove={(damageType) =>
removePersistentDamage(id, damageType)
}
/> />
)}
</ConditionTags>
</div> </div>
{!!pickerOpen && ( {!!pickerOpen && (
<ConditionPicker <ConditionPicker
anchorRef={conditionAnchorRef} anchorRef={conditionAnchorRef}
activeConditions={combatant.conditions} activeConditions={combatant.conditions}
activePersistentDamage={combatant.persistentDamage}
onToggle={(conditionId) => toggleCondition(id, conditionId)} onToggle={(conditionId) => toggleCondition(id, conditionId)}
onSetValue={(conditionId, value) => onSetValue={(conditionId, value) =>
setConditionValue(id, conditionId, value) setConditionValue(id, conditionId, value)
} }
onAddPersistentDamage={(damageType, formula) =>
addPersistentDamage(id, damageType, formula)
}
onClose={() => setPickerOpen(false)} onClose={() => setPickerOpen(false)}
/> />
)} )}
+5 -66
View File
@@ -3,11 +3,9 @@ import {
type ConditionId, type ConditionId,
getConditionDescription, getConditionDescription,
getConditionsForEdition, getConditionsForEdition,
type PersistentDamageEntry,
type PersistentDamageType,
} from "@initiative/domain"; } from "@initiative/domain";
import { Check, Flame, Minus, Plus } from "lucide-react"; import { Check, Minus, Plus } from "lucide-react";
import React, { useLayoutEffect, useRef, useState } from "react"; import { useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js"; import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { useClickOutside } from "../hooks/use-click-outside.js"; import { useClickOutside } from "../hooks/use-click-outside.js";
@@ -16,29 +14,21 @@ import {
CONDITION_COLOR_CLASSES, CONDITION_COLOR_CLASSES,
CONDITION_ICON_MAP, CONDITION_ICON_MAP,
} from "./condition-styles.js"; } from "./condition-styles.js";
import { PersistentDamagePicker } from "./persistent-damage-picker.js";
import { Tooltip } from "./ui/tooltip.js"; import { Tooltip } from "./ui/tooltip.js";
interface ConditionPickerProps { interface ConditionPickerProps {
anchorRef: React.RefObject<HTMLElement | null>; anchorRef: React.RefObject<HTMLElement | null>;
activeConditions: readonly ConditionEntry[] | undefined; activeConditions: readonly ConditionEntry[] | undefined;
activePersistentDamage?: readonly PersistentDamageEntry[];
onToggle: (conditionId: ConditionId) => void; onToggle: (conditionId: ConditionId) => void;
onSetValue: (conditionId: ConditionId, value: number) => void; onSetValue: (conditionId: ConditionId, value: number) => void;
onAddPersistentDamage?: (
damageType: PersistentDamageType,
formula: string,
) => void;
onClose: () => void; onClose: () => void;
} }
export function ConditionPicker({ export function ConditionPicker({
anchorRef, anchorRef,
activeConditions, activeConditions,
activePersistentDamage,
onToggle, onToggle,
onSetValue, onSetValue,
onAddPersistentDamage,
onClose, onClose,
}: Readonly<ConditionPickerProps>) { }: Readonly<ConditionPickerProps>) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -52,7 +42,6 @@ export function ConditionPicker({
id: ConditionId; id: ConditionId;
value: number; value: number;
} | null>(null); } | null>(null);
const [showPersistentDamage, setShowPersistentDamage] = useState(false);
useLayoutEffect(() => { useLayoutEffect(() => {
const anchor = anchorRef.current; const anchor = anchorRef.current;
@@ -82,51 +71,6 @@ export function ConditionPicker({
const activeMap = new Map( const activeMap = new Map(
(activeConditions ?? []).map((e) => [e.id, e.value]), (activeConditions ?? []).map((e) => [e.id, e.value]),
); );
const showPersistentDamageEntry =
edition === "pf2e" && !!onAddPersistentDamage;
const persistentDamageInsertIndex = showPersistentDamageEntry
? conditions.findIndex(
(d) => d.label.localeCompare("Persistent Damage") > 0,
)
: -1;
const persistentDamageEntry = showPersistentDamageEntry ? (
<React.Fragment key="persistent-damage">
<div
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
showPersistentDamage && "bg-card/50",
)}
>
<button
type="button"
className="flex flex-1 items-center gap-2"
onClick={() => setShowPersistentDamage((prev) => !prev)}
>
<Flame
size={14}
className={
showPersistentDamage ? "text-orange-400" : "text-muted-foreground"
}
/>
<span
className={
showPersistentDamage ? "text-foreground" : "text-muted-foreground"
}
>
Persistent Damage
</span>
</button>
</div>
{!!showPersistentDamage && (
<PersistentDamagePicker
activeEntries={activePersistentDamage}
onAdd={onAddPersistentDamage}
onClose={() => setShowPersistentDamage(false)}
/>
)}
</React.Fragment>
) : null;
return createPortal( return createPortal(
<div <div
@@ -138,7 +82,7 @@ export function ConditionPicker({
: { visibility: "hidden" as const } : { visibility: "hidden" as const }
} }
> >
{conditions.map((def, index) => { {conditions.map((def) => {
const Icon = CONDITION_ICON_MAP[def.iconName]; const Icon = CONDITION_ICON_MAP[def.iconName];
if (!Icon) return null; if (!Icon) return null;
const isActive = activeMap.has(def.id); const isActive = activeMap.has(def.id);
@@ -160,9 +104,8 @@ export function ConditionPicker({
}; };
return ( return (
<React.Fragment key={def.id}>
{index === persistentDamageInsertIndex && persistentDamageEntry}
<Tooltip <Tooltip
key={def.id}
content={getConditionDescription(def, edition)} content={getConditionDescription(def, edition)}
className="block" className="block"
> >
@@ -180,9 +123,7 @@ export function ConditionPicker({
<Icon <Icon
size={14} size={14}
className={ className={
isActive || isEditing isActive || isEditing ? colorClass : "text-muted-foreground"
? colorClass
: "text-muted-foreground"
} }
/> />
<span <span
@@ -266,10 +207,8 @@ export function ConditionPicker({
)} )}
</div> </div>
</Tooltip> </Tooltip>
</React.Fragment>
); );
})} })}
{persistentDamageInsertIndex === -1 && persistentDamageEntry}
</div>, </div>,
document.body, document.body,
); );
@@ -11,12 +11,8 @@ import {
Droplet, Droplet,
Droplets, Droplets,
EarOff, EarOff,
Eclipse,
Eye, Eye,
EyeClosed,
EyeOff, EyeOff,
Flame,
FlaskConical,
Footprints, Footprints,
Gem, Gem,
Ghost, Ghost,
@@ -26,20 +22,15 @@ import {
HeartPulse, HeartPulse,
Link, Link,
Moon, Moon,
Orbit,
PersonStanding, PersonStanding,
ShieldMinus, ShieldMinus,
ShieldOff, ShieldOff,
Siren, Siren,
Skull, Skull,
Snail, Snail,
Snowflake,
Sparkle,
Sparkles, Sparkles,
Sun, Sun,
Sword,
TrendingDown, TrendingDown,
Wind,
Zap, Zap,
ZapOff, ZapOff,
} from "lucide-react"; } from "lucide-react";
@@ -56,12 +47,8 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
Droplet, Droplet,
Droplets, Droplets,
EarOff, EarOff,
Eclipse,
Eye, Eye,
EyeClosed,
EyeOff, EyeOff,
Flame,
FlaskConical,
Footprints, Footprints,
Gem, Gem,
Ghost, Ghost,
@@ -71,20 +58,15 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
HeartPulse, HeartPulse,
Link, Link,
Moon, Moon,
Orbit,
PersonStanding, PersonStanding,
ShieldMinus, ShieldMinus,
ShieldOff, ShieldOff,
Siren, Siren,
Skull, Skull,
Snail, Snail,
Snowflake,
Sparkle,
Sparkles, Sparkles,
Sun, Sun,
Sword,
TrendingDown, TrendingDown,
Wind,
Zap, Zap,
ZapOff, ZapOff,
}; };
@@ -94,13 +76,11 @@ export const CONDITION_COLOR_CLASSES: Record<string, string> = {
pink: "text-pink-400", pink: "text-pink-400",
amber: "text-amber-400", amber: "text-amber-400",
orange: "text-orange-400", orange: "text-orange-400",
purple: "text-purple-400",
gray: "text-gray-400", gray: "text-gray-400",
violet: "text-violet-400", violet: "text-violet-400",
yellow: "text-yellow-400", yellow: "text-yellow-400",
slate: "text-slate-400", slate: "text-slate-400",
green: "text-green-400", green: "text-green-400",
lime: "text-lime-400",
indigo: "text-indigo-400", indigo: "text-indigo-400",
sky: "text-sky-400", sky: "text-sky-400",
red: "text-red-400", red: "text-red-400",
@@ -5,7 +5,6 @@ import {
getConditionDescription, getConditionDescription,
} from "@initiative/domain"; } from "@initiative/domain";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import type { ReactNode } from "react";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js"; import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { cn } from "../lib/utils.js"; import { cn } from "../lib/utils.js";
import { import {
@@ -19,7 +18,6 @@ interface ConditionTagsProps {
onRemove: (conditionId: ConditionId) => void; onRemove: (conditionId: ConditionId) => void;
onDecrement: (conditionId: ConditionId) => void; onDecrement: (conditionId: ConditionId) => void;
onOpenPicker: () => void; onOpenPicker: () => void;
children?: ReactNode;
} }
export function ConditionTags({ export function ConditionTags({
@@ -27,7 +25,6 @@ export function ConditionTags({
onRemove, onRemove,
onDecrement, onDecrement,
onOpenPicker, onOpenPicker,
children,
}: Readonly<ConditionTagsProps>) { }: Readonly<ConditionTagsProps>) {
const { edition } = useRulesEditionContext(); const { edition } = useRulesEditionContext();
return ( return (
@@ -72,7 +69,6 @@ export function ConditionTags({
</Tooltip> </Tooltip>
); );
})} })}
{children}
<button <button
type="button" type="button"
title="Add condition" title="Add condition"
@@ -19,21 +19,12 @@ const TIER_LABEL_MAP: Partial<
1: { label: "Low", color: "text-green-500" }, 1: { label: "Low", color: "text-green-500" },
2: { label: "Moderate", color: "text-yellow-500" }, 2: { label: "Moderate", color: "text-yellow-500" },
3: { label: "High", color: "text-red-500" }, 3: { label: "High", color: "text-red-500" },
4: { label: "High", color: "text-red-500" },
}, },
"5e": { "5e": {
0: { label: "Easy", color: "text-muted-foreground" }, 0: { label: "Easy", color: "text-muted-foreground" },
1: { label: "Medium", color: "text-green-500" }, 1: { label: "Medium", color: "text-green-500" },
2: { label: "Hard", color: "text-yellow-500" }, 2: { label: "Hard", color: "text-yellow-500" },
3: { label: "Deadly", color: "text-red-500" }, 3: { label: "Deadly", color: "text-red-500" },
4: { label: "Deadly", color: "text-red-500" },
},
pf2e: {
0: { label: "Trivial", color: "text-muted-foreground" },
1: { label: "Low", color: "text-green-500" },
2: { label: "Moderate", color: "text-yellow-500" },
3: { label: "Severe", color: "text-orange-500" },
4: { label: "Extreme", color: "text-red-500" },
}, },
}; };
@@ -41,9 +32,6 @@ const TIER_LABEL_MAP: Partial<
const SHORT_LABELS: Readonly<Record<string, string>> = { const SHORT_LABELS: Readonly<Record<string, string>> = {
Moderate: "Mod", Moderate: "Mod",
Medium: "Med", Medium: "Med",
Trivial: "Triv",
Severe: "Sev",
Extreme: "Ext",
}; };
function shortLabel(label: string): string { function shortLabel(label: string): string {
@@ -119,54 +107,6 @@ function NpcRow({
); );
} }
function Pf2eNpcRow({
entry,
onToggleSide,
}: {
entry: BreakdownCombatant;
onToggleSide: () => void;
}) {
const isParty = entry.side === "party";
const targetSide = isParty ? "enemy" : "party";
let xpDisplay: string;
if (entry.xp == null) {
xpDisplay = "\u2014";
} else if (isParty) {
xpDisplay = `\u2212${formatXp(entry.xp)}`;
} else {
xpDisplay = formatXp(entry.xp);
}
let levelDisplay: string;
if (entry.creatureLevel === undefined) {
levelDisplay = "\u2014";
} else if (entry.levelDifference === undefined) {
levelDisplay = `Lv ${entry.creatureLevel}`;
} else {
const sign = entry.levelDifference >= 0 ? "+" : "";
levelDisplay = `Lv ${entry.creatureLevel} (${sign}${entry.levelDifference})`;
}
return (
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
<span className="min-w-0 truncate" title={entry.combatant.name}>
{entry.combatant.name}
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleSide}
aria-label={`Move ${entry.combatant.name} to ${targetSide} side`}
>
<ArrowLeftRight className="h-3 w-3" />
</Button>
<span className="text-muted-foreground">{levelDisplay}</span>
<span className="text-right tabular-nums">{xpDisplay}</span>
</div>
);
}
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) { export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, onClose); useClickOutside(ref, onClose);
@@ -188,8 +128,6 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
const isPC = (entry: BreakdownCombatant) => const isPC = (entry: BreakdownCombatant) =>
entry.combatant.playerCharacterId != null; entry.combatant.playerCharacterId != null;
const CreatureRow = edition === "pf2e" ? Pf2eNpcRow : NpcRow;
return ( return (
<div <div
ref={ref} ref={ref}
@@ -204,9 +142,6 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
<div className="mb-1 text-muted-foreground text-xs"> <div className="mb-1 text-muted-foreground text-xs">
Party Budget ({breakdown.pcCount}{" "} Party Budget ({breakdown.pcCount}{" "}
{breakdown.pcCount === 1 ? "PC" : "PCs"}) {breakdown.pcCount === 1 ? "PC" : "PCs"})
{breakdown.partyLevel !== undefined && (
<> &middot; Party Level: {breakdown.partyLevel}</>
)}
</div> </div>
<div className="flex gap-3 text-xs"> <div className="flex gap-3 text-xs">
{breakdown.thresholds.map((t) => ( {breakdown.thresholds.map((t) => (
@@ -231,7 +166,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
isPC(entry) ? ( isPC(entry) ? (
<PcRow key={entry.combatant.id} entry={entry} /> <PcRow key={entry.combatant.id} entry={entry} />
) : ( ) : (
<CreatureRow <NpcRow
key={entry.combatant.id} key={entry.combatant.id}
entry={entry} entry={entry}
onToggleSide={() => handleToggle(entry)} onToggleSide={() => handleToggle(entry)}
@@ -251,7 +186,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
isPC(entry) ? ( isPC(entry) ? (
<PcRow key={entry.combatant.id} entry={entry} /> <PcRow key={entry.combatant.id} entry={entry} />
) : ( ) : (
<CreatureRow <NpcRow
key={entry.combatant.id} key={entry.combatant.id}
entry={entry} entry={entry}
onToggleSide={() => handleToggle(entry)} onToggleSide={() => handleToggle(entry)}
@@ -283,9 +218,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
</div> </div>
) : ( ) : (
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs"> <div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
<span> <span>Net Monster XP</span>
{edition === "pf2e" ? "Net Creature XP" : "Net Monster XP"}
</span>
<span className="tabular-nums"> <span className="tabular-nums">
{formatXp(breakdown.totalMonsterXp)} {formatXp(breakdown.totalMonsterXp)}
</span> </span>
@@ -6,7 +6,6 @@ export const TIER_LABELS_5_5E: Record<DifficultyTier, string> = {
1: "Low", 1: "Low",
2: "Moderate", 2: "Moderate",
3: "High", 3: "High",
4: "High",
}; };
export const TIER_LABELS_2014: Record<DifficultyTier, string> = { export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
@@ -14,49 +13,30 @@ export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
1: "Medium", 1: "Medium",
2: "Hard", 2: "Hard",
3: "Deadly", 3: "Deadly",
4: "Deadly",
}; };
export const TIER_LABELS_PF2E: Record<DifficultyTier, string> = { const TIER_COLORS: Record<
0: "Trivial", DifficultyTier,
1: "Low", { filledBars: number; color: string }
2: "Moderate", > = {
3: "Severe", 0: { filledBars: 0, color: "" },
4: "Extreme", 1: { filledBars: 1, color: "bg-green-500" },
2: { filledBars: 2, color: "bg-yellow-500" },
3: { filledBars: 3, color: "bg-red-500" },
}; };
const BAR_HEIGHTS_3 = ["h-2", "h-3", "h-4"] as const; const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
const BAR_HEIGHTS_4 = ["h-1.5", "h-2", "h-3", "h-4"] as const;
/** Color for the Nth filled bar (1-indexed) in 4-bar mode. */
const BAR_COLORS: Record<number, string> = {
1: "bg-green-500",
2: "bg-yellow-500",
3: "bg-orange-500",
4: "bg-red-500",
};
/** For 3-bar mode, bar 3 uses red directly (skip orange). */
const BAR_COLORS_3: Record<number, string> = {
1: "bg-green-500",
2: "bg-yellow-500",
3: "bg-red-500",
};
export function DifficultyIndicator({ export function DifficultyIndicator({
result, result,
labels, labels,
barCount = 3,
onClick, onClick,
}: { }: {
result: DifficultyResult; result: DifficultyResult;
labels: Record<DifficultyTier, string>; labels: Record<DifficultyTier, string>;
barCount?: 3 | 4;
onClick?: () => void; onClick?: () => void;
}) { }) {
const barHeights = barCount === 4 ? BAR_HEIGHTS_4 : BAR_HEIGHTS_3; const config = TIER_COLORS[result.tier];
const colorMap = barCount === 4 ? BAR_COLORS : BAR_COLORS_3;
const filledBars = result.tier;
const label = labels[result.tier]; const label = labels[result.tier];
const tooltip = `${label} encounter difficulty`; const tooltip = `${label} encounter difficulty`;
@@ -74,13 +54,13 @@ export function DifficultyIndicator({
onClick={onClick} onClick={onClick}
type={onClick ? "button" : undefined} type={onClick ? "button" : undefined}
> >
{barHeights.map((height, i) => ( {BAR_HEIGHTS.map((height, i) => (
<div <div
key={height} key={height}
className={cn( className={cn(
"w-1 rounded-sm", "w-1 rounded-sm",
height, height,
i < filledBars ? colorMap[i + 1] : "bg-muted", i < config.filledBars ? config.color : "bg-muted",
)} )}
/> />
))} ))}
+3 -63
View File
@@ -1,34 +1,17 @@
import type { import type { Creature } from "@initiative/domain";
CombatantId,
Creature,
HpRange,
HpVariant,
} from "@initiative/domain";
import { import {
calculateInitiative, calculateInitiative,
formatInitiativeModifier, formatInitiativeModifier,
hpForVariant,
hpRange,
} from "@initiative/domain"; } from "@initiative/domain";
import { cn } from "../lib/utils.js";
import { import {
PropertyLine, PropertyLine,
SectionDivider, SectionDivider,
TraitEntry, TraitEntry,
TraitSection, TraitSection,
} from "./stat-block-parts.js"; } from "./stat-block-parts.js";
import type { SegmentedOption } from "./ui/segmented-control.js";
import { SegmentedControl } from "./ui/segmented-control.js";
interface DndStatBlockProps { interface DndStatBlockProps {
creature: Creature; creature: Creature;
combatantId?: CombatantId;
hpVariant?: HpVariant;
onSetHpVariant?: (
id: CombatantId,
variant: HpVariant | undefined,
range: HpRange,
) => void;
} }
function abilityMod(score: number): string { function abilityMod(score: number): string {
@@ -36,25 +19,7 @@ function abilityMod(score: number): string {
return mod >= 0 ? `+${mod}` : `${mod}`; return mod >= 0 ? `+${mod}` : `${mod}`;
} }
/** Text color for a max HP that no longer shows the printed average. */ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
function hpVariantColor(variant: HpVariant | undefined): string {
if (variant === "max") return "text-blue-400";
if (variant === "min") return "text-red-400";
return "";
}
const HP_VARIANT_OPTIONS: SegmentedOption<HpVariant | undefined>[] = [
{ value: "min", label: "Min" },
{ value: undefined, label: "Avg" },
{ value: "max", label: "Max" },
];
export function DndStatBlock({
creature,
combatantId,
hpVariant,
onSetHpVariant,
}: Readonly<DndStatBlockProps>) {
const abilities = [ const abilities = [
{ label: "STR", score: creature.abilities.str }, { label: "STR", score: creature.abilities.str },
{ label: "DEX", score: creature.abilities.dex }, { label: "DEX", score: creature.abilities.dex },
@@ -70,17 +35,6 @@ export function DndStatBlock({
initiativeProficiency: creature.initiativeProficiency, initiativeProficiency: creature.initiativeProficiency,
}); });
// Only offer min/max HP when the formula is a real dice pool with a spread.
const range = hpRange(creature.hp);
const canPickVariant =
range !== null &&
range.min !== range.max &&
combatantId != null &&
onSetHpVariant != null;
const displayedHp = range
? hpForVariant(range, hpVariant)
: creature.hp.average;
return ( return (
<div className="space-y-1 text-foreground"> <div className="space-y-1 text-foreground">
{/* Header */} {/* Header */}
@@ -114,22 +68,8 @@ export function DndStatBlock({
</div> </div>
<div> <div>
<span className="font-semibold">Hit Points</span>{" "} <span className="font-semibold">Hit Points</span>{" "}
<span className={cn("font-semibold", hpVariantColor(hpVariant))}> {creature.hp.average}{" "}
{displayedHp}
</span>{" "}
<span className="text-muted-foreground">({creature.hp.formula})</span> <span className="text-muted-foreground">({creature.hp.formula})</span>
{canPickVariant ? (
<SegmentedControl
options={HP_VARIANT_OPTIONS}
value={hpVariant}
onChange={(variant) =>
onSetHpVariant(combatantId, variant, range)
}
size="xs"
label="Hit point variant"
className="mt-1"
/>
) : null}
</div> </div>
<div> <div>
<span className="font-semibold">Speed</span> {creature.speed} <span className="font-semibold">Speed</span> {creature.speed}
@@ -1,6 +1,5 @@
import type { EquipmentItem } from "@initiative/domain"; import type { EquipmentItem } from "@initiative/domain";
import { DetailPopover } from "./detail-popover.js"; import { DetailPopover } from "./detail-popover.js";
import { RichDescription } from "./rich-description.js";
interface EquipmentDetailPopoverProps { interface EquipmentDetailPopoverProps {
readonly item: EquipmentItem; readonly item: EquipmentItem;
@@ -42,10 +41,9 @@ function EquipmentDetailContent({ item }: Readonly<{ item: EquipmentItem }>) {
) : null} ) : null}
</div> </div>
{item.description ? ( {item.description ? (
<RichDescription <p className="whitespace-pre-line text-foreground">
text={item.description} {item.description}
className="whitespace-pre-line text-foreground" </p>
/>
) : ( ) : (
<p className="text-muted-foreground italic"> <p className="text-muted-foreground italic">
No description available. No description available.
+8 -11
View File
@@ -6,21 +6,18 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { createPortal } from "react-dom";
import { useClickOutside } from "../hooks/use-click-outside.js"; import { useClickOutside } from "../hooks/use-click-outside.js";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
const DIGITS_ONLY_REGEX = /^\d+$/; const DIGITS_ONLY_REGEX = /^\d+$/;
interface HpAdjustPopoverProps { interface HpAdjustPopoverProps {
readonly anchorRef: React.RefObject<HTMLElement | null>;
readonly onAdjust: (delta: number) => void; readonly onAdjust: (delta: number) => void;
readonly onSetTempHp: (value: number) => void; readonly onSetTempHp: (value: number) => void;
readonly onClose: () => void; readonly onClose: () => void;
} }
export function HpAdjustPopover({ export function HpAdjustPopover({
anchorRef,
onAdjust, onAdjust,
onSetTempHp, onSetTempHp,
onClose, onClose,
@@ -32,9 +29,10 @@ export function HpAdjustPopover({
useLayoutEffect(() => { useLayoutEffect(() => {
const el = ref.current; const el = ref.current;
const anchor = anchorRef.current; if (!el) return;
if (!el || !anchor) return; const parent = el.parentElement;
const trigger = anchor.getBoundingClientRect(); if (!parent) return;
const trigger = parent.getBoundingClientRect();
const popover = el.getBoundingClientRect(); const popover = el.getBoundingClientRect();
const vw = document.documentElement.clientWidth; const vw = document.documentElement.clientWidth;
let left = trigger.left; let left = trigger.left;
@@ -45,7 +43,7 @@ export function HpAdjustPopover({
left = 8; left = 8;
} }
setPos({ top: trigger.bottom + 4, left }); setPos({ top: trigger.bottom + 4, left });
}, [anchorRef]); }, []);
useEffect(() => { useEffect(() => {
requestAnimationFrame(() => inputRef.current?.focus()); requestAnimationFrame(() => inputRef.current?.focus());
@@ -84,10 +82,10 @@ export function HpAdjustPopover({
[applyDelta, onClose], [applyDelta, onClose],
); );
return createPortal( return (
<div <div
ref={ref} ref={ref}
className="card-glow fixed z-50 rounded-lg border border-border bg-background p-2" className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
style={ style={
pos pos
? { top: pos.top, left: pos.left } ? { top: pos.top, left: pos.left }
@@ -146,7 +144,6 @@ export function HpAdjustPopover({
<ShieldPlus size={14} /> <ShieldPlus size={14} />
</button> </button>
</div> </div>
</div>, </div>
document.body,
); );
} }
@@ -1,97 +0,0 @@
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>
);
}
@@ -1,63 +0,0 @@
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>
);
})}
</>
);
}
+7 -62
View File
@@ -1,13 +1,10 @@
import type { import type {
CombatantId,
EquipmentItem, EquipmentItem,
Pf2eCreature, Pf2eCreature,
SpellReference, SpellReference,
} from "@initiative/domain"; } from "@initiative/domain";
import { formatInitiativeModifier, recallKnowledge } from "@initiative/domain"; import { formatInitiativeModifier, recallKnowledge } from "@initiative/domain";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { cn } from "../lib/utils.js";
import { EquipmentDetailPopover } from "./equipment-detail-popover.js"; import { EquipmentDetailPopover } from "./equipment-detail-popover.js";
import { SpellDetailPopover } from "./spell-detail-popover.js"; import { SpellDetailPopover } from "./spell-detail-popover.js";
import { import {
@@ -15,19 +12,9 @@ import {
SectionDivider, SectionDivider,
TraitSection, TraitSection,
} from "./stat-block-parts.js"; } from "./stat-block-parts.js";
import type { SegmentedOption } from "./ui/segmented-control.js";
import { SegmentedControl } from "./ui/segmented-control.js";
interface Pf2eStatBlockProps { interface Pf2eStatBlockProps {
creature: Pf2eCreature; creature: Pf2eCreature;
adjustment?: "weak" | "elite";
combatantId?: CombatantId;
baseCreature?: Pf2eCreature;
onSetAdjustment?: (
id: CombatantId,
adj: "weak" | "elite" | undefined,
base: Pf2eCreature,
) => void;
} }
const ALIGNMENTS = new Set([ const ALIGNMENTS = new Set([
@@ -54,19 +41,6 @@ function formatMod(mod: number): string {
return mod >= 0 ? `+${mod}` : `${mod}`; 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 { interface SpellLinkProps {
readonly spell: SpellReference; readonly spell: SpellReference;
readonly onOpen: (spell: SpellReference, rect: DOMRect) => void; readonly onOpen: (spell: SpellReference, rect: DOMRect) => void;
@@ -162,13 +136,7 @@ function EquipmentLink({ item, onOpen }: Readonly<EquipmentLinkProps>) {
); );
} }
export function Pf2eStatBlock({ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
creature,
adjustment,
combatantId,
baseCreature,
onSetAdjustment,
}: Readonly<Pf2eStatBlockProps>) {
const [openSpell, setOpenSpell] = useState<{ const [openSpell, setOpenSpell] = useState<{
spell: SpellReference; spell: SpellReference;
rect: DOMRect; rect: DOMRect;
@@ -189,7 +157,6 @@ export function Pf2eStatBlock({
const handleCloseEquipment = useCallback(() => setOpenEquipment(null), []); const handleCloseEquipment = useCallback(() => setOpenEquipment(null), []);
const rk = recallKnowledge(creature.level, creature.traits); const rk = recallKnowledge(creature.level, creature.traits);
const adjColor = adjustmentColor(adjustment);
const abilityEntries = [ const abilityEntries = [
{ label: "Str", mod: creature.abilityMods.str }, { label: "Str", mod: creature.abilityMods.str },
@@ -205,33 +172,13 @@ export function Pf2eStatBlock({
{/* Header */} {/* Header */}
<div> <div>
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<h2 className="flex items-center gap-1.5 font-bold text-stat-heading text-xl"> <h2 className="font-bold text-stat-heading text-xl">
{adjustment === "elite" && (
<ChevronUp className="h-5 w-5 shrink-0 text-blue-400" />
)}
{adjustment === "weak" && (
<ChevronDown className="h-5 w-5 shrink-0 text-red-400" />
)}
{creature.name} {creature.name}
</h2> </h2>
<span className={cn("shrink-0 font-semibold text-sm", adjColor)}> <span className="shrink-0 font-semibold text-sm">
Level {creature.level} Level {creature.level}
</span> </span>
</div> </div>
{combatantId != null &&
onSetAdjustment != null &&
baseCreature != null && (
<SegmentedControl
options={ADJUSTMENT_OPTIONS}
value={adjustment}
onChange={(value) =>
onSetAdjustment(combatantId, value, baseCreature)
}
size="xs"
label="Creature adjustment"
className="mt-1"
/>
)}
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{displayTraits(creature.traits).map((trait) => ( {displayTraits(creature.traits).map((trait) => (
<span <span
@@ -257,12 +204,10 @@ export function Pf2eStatBlock({
{/* Perception, Languages, Skills */} {/* Perception, Languages, Skills */}
<div className="space-y-0.5 text-sm"> <div className="space-y-0.5 text-sm">
<div className={adjColor}> <div>
<span className="font-semibold">Perception</span>{" "} <span className="font-semibold">Perception</span>{" "}
{formatInitiativeModifier(creature.perception)} {formatInitiativeModifier(creature.perception)}
{creature.senses || creature.perceptionDetails {creature.senses ? `; ${creature.senses}` : ""}
? `; ${[creature.senses, creature.perceptionDetails].filter(Boolean).join(", ")}`
: ""}
</div> </div>
<PropertyLine label="Languages" value={creature.languages} /> <PropertyLine label="Languages" value={creature.languages} />
<PropertyLine label="Skills" value={creature.skills} /> <PropertyLine label="Skills" value={creature.skills} />
@@ -289,7 +234,7 @@ export function Pf2eStatBlock({
{/* Defenses */} {/* Defenses */}
<div className="space-y-0.5 text-sm"> <div className="space-y-0.5 text-sm">
<div className={adjColor}> <div>
<span className="font-semibold">AC</span> {creature.ac} <span className="font-semibold">AC</span> {creature.ac}
{creature.acConditional ? ` (${creature.acConditional})` : ""};{" "} {creature.acConditional ? ` (${creature.acConditional})` : ""};{" "}
<span className="font-semibold">Fort</span>{" "} <span className="font-semibold">Fort</span>{" "}
@@ -300,7 +245,7 @@ export function Pf2eStatBlock({
{formatMod(creature.saveWill)} {formatMod(creature.saveWill)}
{creature.saveConditional ? `; ${creature.saveConditional}` : ""} {creature.saveConditional ? `; ${creature.saveConditional}` : ""}
</div> </div>
<div className={adjColor}> <div>
<span className="font-semibold">HP</span> {creature.hp} <span className="font-semibold">HP</span> {creature.hp}
{creature.hpDetails ? ` (${creature.hpDetails})` : ""} {creature.hpDetails ? ` (${creature.hpDetails})` : ""}
</div> </div>
@@ -1,20 +0,0 @@
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} />;
}
+48 -47
View File
@@ -2,51 +2,28 @@ import type { RulesEdition } from "@initiative/domain";
import { Monitor, Moon, Sun } from "lucide-react"; import { Monitor, Moon, Sun } from "lucide-react";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js"; import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
import { useThemeContext } from "../contexts/theme-context.js"; import { useThemeContext } from "../contexts/theme-context.js";
import { cn } from "../lib/utils.js";
import { Dialog, DialogHeader } from "./ui/dialog.js"; import { Dialog, DialogHeader } from "./ui/dialog.js";
import type { SegmentedOption } from "./ui/segmented-control.js";
import { SegmentedControl } from "./ui/segmented-control.js";
interface SettingsModalProps { interface SettingsModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
} }
const EDITION_OPTIONS: SegmentedOption<RulesEdition>[] = [ const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
{ value: "5e", label: "5e (2014)" }, { value: "5e", label: "5e (2014)" },
{ value: "5.5e", label: "5.5e (2024)" }, { value: "5.5e", label: "5.5e (2024)" },
{ value: "pf2e", label: "Pathfinder 2e" }, { value: "pf2e", label: "Pathfinder 2e" },
]; ];
type ThemePreference = "system" | "light" | "dark"; const THEME_OPTIONS: {
value: "system" | "light" | "dark";
const THEME_OPTIONS: SegmentedOption<ThemePreference>[] = [ label: string;
{ icon: typeof Sun;
value: "system", }[] = [
label: ( { value: "system", label: "System", icon: Monitor },
<> { value: "light", label: "Light", icon: Sun },
<Monitor size={14} /> { value: "dark", label: "Dark", icon: Moon },
System
</>
),
},
{
value: "light",
label: (
<>
<Sun size={14} />
Light
</>
),
},
{
value: "dark",
label: (
<>
<Moon size={14} />
Dark
</>
),
},
]; ];
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) { export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
@@ -62,26 +39,50 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
<span className="mb-2 block font-medium text-muted-foreground text-sm"> <span className="mb-2 block font-medium text-muted-foreground text-sm">
Game System Game System
</span> </span>
<SegmentedControl <div className="flex gap-1">
options={EDITION_OPTIONS} {EDITION_OPTIONS.map((opt) => (
value={edition} <button
onChange={setEdition} key={opt.value}
stretch type="button"
label="Game System" 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>
</div> </div>
<div> <div>
<span className="mb-2 block font-medium text-muted-foreground text-sm"> <span className="mb-2 block font-medium text-muted-foreground text-sm">
Theme Theme
</span> </span>
<SegmentedControl <div className="flex gap-1">
options={THEME_OPTIONS} {THEME_OPTIONS.map((opt) => {
value={preference} const Icon = opt.icon;
onChange={setPreference} return (
stretch <button
label="Theme" 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>
</div> </div>
</div> </div>
</Dialog> </Dialog>
@@ -8,7 +8,7 @@ import { Input } from "./ui/input.js";
interface SourceFetchPromptProps { interface SourceFetchPromptProps {
sourceCode: string; sourceCode: string;
onSourceLoaded: (skippedNames: string[]) => void; onSourceLoaded: () => void;
} }
export function SourceFetchPrompt({ export function SourceFetchPrompt({
@@ -32,9 +32,8 @@ export function SourceFetchPrompt({
setStatus("fetching"); setStatus("fetching");
setError(""); setError("");
try { try {
const { skippedNames } = await fetchAndCacheSource(sourceCode, url); await fetchAndCacheSource(sourceCode, url);
setStatus("idle"); onSourceLoaded();
onSourceLoaded(skippedNames);
} catch (e) { } catch (e) {
setStatus("error"); setStatus("error");
setError(e instanceof Error ? e.message : "Failed to fetch source data"); setError(e instanceof Error ? e.message : "Failed to fetch source data");
@@ -52,7 +51,7 @@ export function SourceFetchPrompt({
const text = await file.text(); const text = await file.text();
const json = JSON.parse(text); const json = JSON.parse(text);
await uploadAndCacheSource(sourceCode, json); await uploadAndCacheSource(sourceCode, json);
onSourceLoaded([]); onSourceLoaded();
} catch (err) { } catch (err) {
setStatus("error"); setStatus("error");
setError( setError(
@@ -1,6 +1,5 @@
import type { ActivityCost, SpellReference } from "@initiative/domain"; import type { ActivityCost, SpellReference } from "@initiative/domain";
import { DetailPopover } from "./detail-popover.js"; import { DetailPopover } from "./detail-popover.js";
import { RichDescription } from "./rich-description.js";
import { ActivityIcon } from "./stat-block-parts.js"; import { ActivityIcon } from "./stat-block-parts.js";
interface SpellDetailPopoverProps { interface SpellDetailPopoverProps {
@@ -135,6 +134,24 @@ 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 }>) { function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) {
return ( return (
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
@@ -142,20 +159,16 @@ function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) {
<SpellTraits traits={spell.traits ?? []} /> <SpellTraits traits={spell.traits ?? []} />
<SpellMeta spell={spell} /> <SpellMeta spell={spell} />
{spell.description ? ( {spell.description ? (
<RichDescription <SpellDescription text={spell.description} />
text={spell.description}
className="whitespace-pre-line text-foreground"
/>
) : ( ) : (
<p className="text-muted-foreground italic"> <p className="text-muted-foreground italic">
No description available. No description available.
</p> </p>
)} )}
{spell.heightening ? ( {spell.heightening ? (
<RichDescription <p className="whitespace-pre-line text-foreground text-xs">
text={spell.heightening} {spell.heightening}
className="whitespace-pre-line text-foreground text-xs" </p>
/>
) : null} ) : null}
</div> </div>
); );
+14 -99
View File
@@ -1,19 +1,8 @@
import type { import type { Creature, CreatureId } from "@initiative/domain";
AnyCreature,
Combatant,
CombatantId,
Creature,
CreatureId,
HpRange,
HpVariant,
Pf2eCreature,
} from "@initiative/domain";
import { applyPf2eAdjustment } from "@initiative/domain";
import { PanelRightClose, Pin, PinOff } from "lucide-react"; import { PanelRightClose, Pin, PinOff } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useBestiaryContext } from "../contexts/bestiary-context.js"; import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js"; import { useSidePanelContext } from "../contexts/side-panel-context.js";
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js"; import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
import { cn } from "../lib/utils.js"; import { cn } from "../lib/utils.js";
@@ -22,7 +11,6 @@ import { DndStatBlock } from "./dnd-stat-block.js";
import { Pf2eStatBlock } from "./pf2e-stat-block.js"; import { Pf2eStatBlock } from "./pf2e-stat-block.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js"; import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { SourceManager } from "./source-manager.js"; import { SourceManager } from "./source-manager.js";
import { Toast } from "./toast.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
interface StatBlockPanelProps { interface StatBlockPanelProps {
@@ -227,8 +215,6 @@ function MobileDrawer({
function usePanelRole(panelRole: "browse" | "pinned") { function usePanelRole(panelRole: "browse" | "pinned") {
const sidePanel = useSidePanelContext(); const sidePanel = useSidePanelContext();
const { getCreature } = useBestiaryContext(); const { getCreature } = useBestiaryContext();
const { encounter, setCreatureAdjustment, setHpVariant } =
useEncounterContext();
const creatureId = const creatureId =
panelRole === "browse" panelRole === "browse"
@@ -236,19 +222,10 @@ function usePanelRole(panelRole: "browse" | "pinned") {
: sidePanel.pinnedCreatureId; : sidePanel.pinnedCreatureId;
const creature = creatureId ? (getCreature(creatureId) ?? null) : null; const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
const combatantId =
panelRole === "browse" ? sidePanel.selectedCombatantId : null;
const combatant = combatantId
? (encounter.combatants.find((c) => c.id === combatantId) ?? null)
: null;
const isBrowse = panelRole === "browse"; const isBrowse = panelRole === "browse";
return { return {
creatureId, creatureId,
creature, creature,
combatant,
setCreatureAdjustment,
setHpVariant,
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false, isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {}, onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {}, onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
@@ -260,59 +237,14 @@ function usePanelRole(panelRole: "browse" | "pinned") {
}; };
} }
interface StatBlockHandlers {
setCreatureAdjustment: (
id: CombatantId,
adj: "weak" | "elite" | undefined,
base: Pf2eCreature,
) => void;
setHpVariant: (
id: CombatantId,
variant: HpVariant | undefined,
range: HpRange,
) => void;
}
function renderStatBlock(
creature: AnyCreature,
combatant: Combatant | null,
handlers: StatBlockHandlers,
) {
if ("system" in creature && creature.system === "pf2e") {
const baseCreature = creature;
const adjusted = combatant?.creatureAdjustment
? applyPf2eAdjustment(baseCreature, combatant.creatureAdjustment)
: baseCreature;
return (
<Pf2eStatBlock
creature={adjusted}
adjustment={combatant?.creatureAdjustment}
combatantId={combatant?.id}
baseCreature={baseCreature}
onSetAdjustment={handlers.setCreatureAdjustment}
/>
);
}
return (
<DndStatBlock
creature={creature as Creature}
combatantId={combatant?.id}
hpVariant={combatant?.hpVariant}
onSetHpVariant={handlers.setHpVariant}
/>
);
}
export function StatBlockPanel({ export function StatBlockPanel({
panelRole, panelRole,
side, side,
}: Readonly<StatBlockPanelProps>) { }: Readonly<StatBlockPanelProps>) {
const { isSourceCached } = useBestiaryContext();
const { const {
creatureId, creatureId,
creature, creature,
combatant,
setCreatureAdjustment,
setHpVariant,
isCollapsed, isCollapsed,
onToggleCollapse, onToggleCollapse,
onDismiss, onDismiss,
@@ -328,7 +260,6 @@ export function StatBlockPanel({
); );
const [needsFetch, setNeedsFetch] = useState(false); const [needsFetch, setNeedsFetch] = useState(false);
const [checkingCache, setCheckingCache] = useState(false); const [checkingCache, setCheckingCache] = useState(false);
const [skippedToast, setSkippedToast] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const mq = globalThis.matchMedia("(min-width: 1024px)"); const mq = globalThis.matchMedia("(min-width: 1024px)");
@@ -349,23 +280,19 @@ export function StatBlockPanel({
return; return;
} }
// Show fetch prompt both when source is uncached AND when the source is setCheckingCache(true);
// cached but this specific creature is missing (e.g. skipped by ad blocker). void isSourceCached(sourceCode).then((cached) => {
setNeedsFetch(true); setNeedsFetch(!cached);
setCheckingCache(false); setCheckingCache(false);
}, [creatureId, creature]); });
}, [creatureId, creature, isSourceCached]);
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null; if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
const sourceCode = creatureId ? extractSourceCode(creatureId) : ""; const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
const handleSourceLoaded = (skippedNames: string[]) => { const handleSourceLoaded = () => {
if (skippedNames.length > 0) { setNeedsFetch(false);
const names = skippedNames.join(", ");
setSkippedToast(
`${skippedNames.length} creature(s) skipped (ad blocker?): ${names}`,
);
}
}; };
const renderContent = () => { const renderContent = () => {
@@ -384,10 +311,10 @@ export function StatBlockPanel({
} }
if (creature) { if (creature) {
return renderStatBlock(creature, combatant, { if ("system" in creature && creature.system === "pf2e") {
setCreatureAdjustment, return <Pf2eStatBlock creature={creature} />;
setHpVariant, }
}); return <DndStatBlock creature={creature as Creature} />;
} }
if (needsFetch && sourceCode) { if (needsFetch && sourceCode) {
@@ -411,13 +338,8 @@ export function StatBlockPanel({
else if (bulkImportMode) fallbackName = "Import All Sources"; else if (bulkImportMode) fallbackName = "Import All Sources";
const creatureName = creature?.name ?? fallbackName; const creatureName = creature?.name ?? fallbackName;
const toast = skippedToast ? (
<Toast message={skippedToast} onDismiss={() => setSkippedToast(null)} />
) : null;
if (isDesktop) { if (isDesktop) {
return ( return (
<>
<DesktopPanel <DesktopPanel
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
side={side} side={side}
@@ -430,17 +352,10 @@ export function StatBlockPanel({
> >
{renderContent()} {renderContent()}
</DesktopPanel> </DesktopPanel>
{toast}
</>
); );
} }
if (panelRole === "pinned" || isCollapsed) return null; if (panelRole === "pinned" || isCollapsed) return null;
return ( return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
<>
<MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>
{toast}
</>
);
} }
+6 -10
View File
@@ -3,7 +3,6 @@ import type {
TraitBlock, TraitBlock,
TraitSegment, TraitSegment,
} from "@initiative/domain"; } from "@initiative/domain";
import { RichDescription } from "./rich-description.js";
export function PropertyLine({ export function PropertyLine({
label, label,
@@ -40,22 +39,20 @@ function TraitSegments({
{segments.map((seg, i) => { {segments.map((seg, i) => {
if (seg.type === "text") { if (seg.type === "text") {
return ( return (
<RichDescription <span key={segmentKey(seg)}>
key={segmentKey(seg)} {i === 0 ? ` ${seg.value}` : seg.value}
text={i === 0 ? ` ${seg.value}` : seg.value} </span>
className="inline whitespace-pre-line"
/>
); );
} }
return ( return (
<div key={segmentKey(seg)} className="mt-1 space-y-0.5"> <div key={segmentKey(seg)} className="mt-1 space-y-0.5">
{seg.items.map((item) => ( {seg.items.map((item) => (
<div key={item.label ?? item.text}> <p key={item.label ?? item.text}>
{item.label != null && ( {item.label != null && (
<span className="font-semibold">{item.label}. </span> <span className="font-semibold">{item.label}. </span>
)} )}
<RichDescription text={item.text} className="inline" /> {item.text}
</div> </p>
))} ))}
</div> </div>
); );
@@ -141,7 +138,6 @@ export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
</> </>
) : null} ) : null}
</span> </span>
{trait.frequency ? ` (${trait.frequency})` : null}
{trait.trigger ? ( {trait.trigger ? (
<> <>
{" "} {" "}
+1 -9
View File
@@ -8,7 +8,6 @@ import {
DifficultyIndicator, DifficultyIndicator,
TIER_LABELS_5_5E, TIER_LABELS_5_5E,
TIER_LABELS_2014, TIER_LABELS_2014,
TIER_LABELS_PF2E,
} from "./difficulty-indicator.js"; } from "./difficulty-indicator.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { ConfirmButton } from "./ui/confirm-button.js"; import { ConfirmButton } from "./ui/confirm-button.js";
@@ -27,13 +26,7 @@ export function TurnNavigation() {
const difficulty = useDifficulty(); const difficulty = useDifficulty();
const { edition } = useRulesEditionContext(); const { edition } = useRulesEditionContext();
const TIER_LABELS_BY_EDITION = { const tierLabels = edition === "5e" ? TIER_LABELS_2014 : TIER_LABELS_5_5E;
pf2e: TIER_LABELS_PF2E,
"5e": TIER_LABELS_2014,
"5.5e": TIER_LABELS_5_5E,
} as const;
const tierLabels = TIER_LABELS_BY_EDITION[edition];
const barCount = edition === "pf2e" ? 4 : 3;
const [showBreakdown, setShowBreakdown] = useState(false); const [showBreakdown, setShowBreakdown] = useState(false);
const hasCombatants = encounter.combatants.length > 0; const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0; const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
@@ -94,7 +87,6 @@ export function TurnNavigation() {
<DifficultyIndicator <DifficultyIndicator
result={difficulty} result={difficulty}
labels={tierLabels} labels={tierLabels}
barCount={barCount}
onClick={() => setShowBreakdown((prev) => !prev)} onClick={() => setShowBreakdown((prev) => !prev)}
/> />
{showBreakdown ? ( {showBreakdown ? (
+1 -1
View File
@@ -46,7 +46,7 @@ export function Dialog({ open, onClose, className, children }: DialogProps) {
className, className,
)} )}
> >
{open ? <div className="p-6">{children}</div> : null} <div className="p-6">{children}</div>
</dialog> </dialog>
); );
} }
@@ -1,59 +0,0 @@
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,9 +1,5 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import type { import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
AnyCreature,
CreatureId,
PlayerCharacter,
} from "@initiative/domain";
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@@ -13,7 +9,6 @@ import {
buildCombatant, buildCombatant,
buildCreature, buildCreature,
buildEncounter, buildEncounter,
buildPf2eCreature,
} from "../../__tests__/factories/index.js"; } from "../../__tests__/factories/index.js";
import { AllProviders } from "../../__tests__/test-providers.js"; import { AllProviders } from "../../__tests__/test-providers.js";
import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js"; import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js";
@@ -47,7 +42,7 @@ const goblinCreature = buildCreature({
function makeWrapper(options: { function makeWrapper(options: {
encounter: ReturnType<typeof buildEncounter>; encounter: ReturnType<typeof buildEncounter>;
playerCharacters?: PlayerCharacter[]; playerCharacters?: PlayerCharacter[];
creatures?: Map<CreatureId, AnyCreature>; creatures?: Map<CreatureId, Creature>;
}) { }) {
const adapters = createTestAdapters({ const adapters = createTestAdapters({
encounter: options.encounter, encounter: options.encounter,
@@ -350,115 +345,4 @@ describe("useDifficultyBreakdown", () => {
editionResult.current.setEdition("5.5e"); editionResult.current.setEdition("5.5e");
} }
}); });
describe("PF2e edition", () => {
const orcWarrior = buildPf2eCreature({
id: creatureId("pf2e:orc-warrior"),
name: "Orc Warrior",
level: 3,
source: "crb",
sourceDisplayName: "Core Rulebook",
});
it("returns breakdown with creatureLevel, levelDifference, and XP for PF2e creatures", async () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c-1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c-2"),
name: "Orc Warrior",
creatureId: orcWarrior.id,
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[orcWarrior.id, orcWarrior]]),
});
const { result: editionResult } = renderHook(() => useRulesEdition(), {
wrapper,
});
editionResult.current.setEdition("pf2e");
try {
const { result } = renderHook(() => useDifficultyBreakdown(), {
wrapper,
});
await waitFor(() => {
const breakdown = result.current;
expect(breakdown).not.toBeNull();
// Party level should be 5
expect(breakdown?.partyLevel).toBe(5);
// Orc Warrior: level 3, party level 5 → diff 2 → 20 XP
const orc = breakdown?.enemyCombatants[0];
expect(orc?.creatureLevel).toBe(3);
expect(orc?.levelDifference).toBe(-2);
expect(orc?.xp).toBe(20);
expect(orc?.cr).toBeNull();
expect(orc?.source).toBe("Core Rulebook");
// PC should have no creature level
const pc = breakdown?.partyCombatants[0];
expect(pc?.creatureLevel).toBeUndefined();
expect(pc?.levelDifference).toBeUndefined();
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
it("returns partyLevel in result", async () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c-1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c-2"),
name: "Orc Warrior",
creatureId: orcWarrior.id,
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[orcWarrior.id, orcWarrior]]),
});
const { result: editionResult } = renderHook(() => useRulesEdition(), {
wrapper,
});
editionResult.current.setEdition("pf2e");
try {
const { result } = renderHook(() => useDifficultyBreakdown(), {
wrapper,
});
await waitFor(() => {
expect(result.current).not.toBeNull();
expect(result.current?.partyLevel).toBe(5);
// 5 thresholds for PF2e
expect(result.current?.thresholds).toHaveLength(5);
expect(result.current?.thresholds[0].label).toBe("Trivial");
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
});
}); });
@@ -1,9 +1,5 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import type { import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
AnyCreature,
CreatureId,
PlayerCharacter,
} from "@initiative/domain";
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@@ -13,7 +9,6 @@ import {
buildCombatant, buildCombatant,
buildCreature, buildCreature,
buildEncounter, buildEncounter,
buildPf2eCreature,
} from "../../__tests__/factories/index.js"; } from "../../__tests__/factories/index.js";
import { AllProviders } from "../../__tests__/test-providers.js"; import { AllProviders } from "../../__tests__/test-providers.js";
import { useDifficulty } from "../use-difficulty.js"; import { useDifficulty } from "../use-difficulty.js";
@@ -48,7 +43,7 @@ const goblinCreature = buildCreature({
function makeWrapper(options: { function makeWrapper(options: {
encounter: ReturnType<typeof buildEncounter>; encounter: ReturnType<typeof buildEncounter>;
playerCharacters?: PlayerCharacter[]; playerCharacters?: PlayerCharacter[];
creatures?: Map<CreatureId, AnyCreature>; creatures?: Map<CreatureId, Creature>;
}) { }) {
const adapters = createTestAdapters({ const adapters = createTestAdapters({
encounter: options.encounter, encounter: options.encounter,
@@ -341,10 +336,10 @@ describe("useDifficulty", () => {
await waitFor(() => { await waitFor(() => {
expect(result.current).not.toBeNull(); expect(result.current).not.toBeNull();
// Level 3 budget: low=150, mod=225, high=400 // Level 3 budget: low=150, mod=225, high=400
// CR 1/4 = 50 XP ≤ 150 Low budget -> Low // CR 1/4 = 50 XP -> trivial
expect(result.current?.thresholds[0].value).toBe(150); expect(result.current?.thresholds[0].value).toBe(150);
expect(result.current?.totalMonsterXp).toBe(50); expect(result.current?.totalMonsterXp).toBe(50);
expect(result.current?.tier).toBe(1); expect(result.current?.tier).toBe(0);
}); });
}); });
@@ -429,134 +424,4 @@ describe("useDifficulty", () => {
expect(result.current?.totalMonsterXp).toBe(0); expect(result.current?.totalMonsterXp).toBe(0);
}); });
}); });
describe("PF2e edition", () => {
const pf2eCreature = buildPf2eCreature({
id: creatureId("pf2e:orc-warrior"),
name: "Orc Warrior",
level: 5,
});
function makePf2eWrapper(options: {
encounter: ReturnType<typeof buildEncounter>;
playerCharacters?: PlayerCharacter[];
creatures?: Map<CreatureId, AnyCreature>;
}) {
const adapters = createTestAdapters({
encounter: options.encounter,
playerCharacters: options.playerCharacters ?? [],
creatures: options.creatures,
});
return ({ children }: { children: ReactNode }) => (
<AllProviders adapters={adapters}>{children}</AllProviders>
);
}
it("returns result for PF2e with leveled PCs and PF2e creatures", async () => {
const wrapper = makePf2eWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c2"),
name: "Orc Warrior",
creatureId: pf2eCreature.id,
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[pf2eCreature.id, pf2eCreature]]),
});
const { result: editionResult } = renderHook(() => useRulesEdition(), {
wrapper,
});
editionResult.current.setEdition("pf2e");
try {
const { result } = renderHook(() => useDifficulty(), { wrapper });
await waitFor(() => {
expect(result.current).not.toBeNull();
// Creature level 5, party level 5 → diff 0 → 40 XP
expect(result.current?.totalMonsterXp).toBe(40);
expect(result.current?.partyLevel).toBe(5);
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
it("returns null for PF2e when no PF2e creatures with level", () => {
const wrapper = makePf2eWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c2"),
name: "Custom Monster",
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
});
const { result: editionResult } = renderHook(() => useRulesEdition(), {
wrapper,
});
editionResult.current.setEdition("pf2e");
try {
const { result } = renderHook(() => useDifficulty(), { wrapper });
expect(result.current).toBeNull();
} finally {
editionResult.current.setEdition("5.5e");
}
});
it("returns null for PF2e when no PCs with level", () => {
const wrapper = makePf2eWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c2"),
name: "Orc Warrior",
creatureId: pf2eCreature.id,
}),
],
}),
playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
creatures: new Map([[pf2eCreature.id, pf2eCreature]]),
});
const { result: editionResult } = renderHook(() => useRulesEdition(), {
wrapper,
});
editionResult.current.setEdition("pf2e");
try {
const { result } = renderHook(() => useDifficulty(), { wrapper });
expect(result.current).toBeNull();
} finally {
editionResult.current.setEdition("5.5e");
}
});
});
}); });
@@ -1,238 +0,0 @@
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);
});
});
@@ -1,95 +0,0 @@
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,49 +112,6 @@ describe("usePlayerCharacters", () => {
expect(result.current.characters[0].name).toBe("Vex'ahlia"); expect(result.current.characters[0].name).toBe("Vex'ahlia");
}); });
it("createCharacter assigns a fresh id after rehydration from persistence", () => {
const stored = [
{
id: playerCharacterId("pc-1"),
name: "Mikka",
ac: 12,
maxHp: 58,
color: undefined,
icon: undefined,
},
{
id: playerCharacterId("pc-3"),
name: "Bob",
ac: 14,
maxHp: 40,
color: undefined,
icon: undefined,
},
];
const adapters = createTestAdapters({ playerCharacters: stored });
const { result } = renderHook(() => usePlayerCharacters(), {
wrapper: ({ children }: { children: ReactNode }) => (
<AllProviders adapters={adapters}>{children}</AllProviders>
),
});
act(() => {
result.current.createCharacter(
"Charlie",
13,
25,
undefined,
undefined,
undefined,
);
});
const ids = result.current.characters.map((pc) => pc.id);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).toContain(playerCharacterId("pc-4"));
});
it("deleteCharacter removes character and persists", () => { it("deleteCharacter removes character and persists", () => {
const { result } = renderHook(() => usePlayerCharacters(), { wrapper }); const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
+4 -10
View File
@@ -6,8 +6,8 @@ export function useAutoStatBlock(): void {
const { encounter } = useEncounterContext(); const { encounter } = useEncounterContext();
const { panelView, updateCreature } = useSidePanelContext(); const { panelView, updateCreature } = useSidePanelContext();
const activeCombatant = encounter.combatants[encounter.activeIndex]; const activeCreatureId =
const activeCreatureId = activeCombatant?.creatureId; encounter.combatants[encounter.activeIndex]?.creatureId;
const prevActiveIndexRef = useRef(encounter.activeIndex); const prevActiveIndexRef = useRef(encounter.activeIndex);
useEffect(() => { useEffect(() => {
@@ -21,13 +21,7 @@ export function useAutoStatBlock(): void {
activeCreatureId && activeCreatureId &&
panelView.mode === "creature" panelView.mode === "creature"
) { ) {
updateCreature(activeCreatureId, activeCombatant.id); updateCreature(activeCreatureId);
} }
}, [ }, [encounter.activeIndex, activeCreatureId, panelView.mode, updateCreature]);
encounter.activeIndex,
activeCreatureId,
activeCombatant?.id,
panelView.mode,
updateCreature,
]);
} }
+18 -127
View File
@@ -9,7 +9,6 @@ import {
normalizeBestiary, normalizeBestiary,
setSourceDisplayNames, setSourceDisplayNames,
} from "../adapters/bestiary-adapter.js"; } from "../adapters/bestiary-adapter.js";
import { loadBundledDndCreatures } from "../adapters/dnd-bundled-adapter.js";
import { normalizeFoundryCreatures } from "../adapters/pf2e-bestiary-adapter.js"; import { normalizeFoundryCreatures } from "../adapters/pf2e-bestiary-adapter.js";
import { useAdapters } from "../contexts/adapter-context.js"; import { useAdapters } from "../contexts/adapter-context.js";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js"; import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
@@ -29,10 +28,7 @@ interface BestiaryHook {
getCreature: (id: CreatureId) => AnyCreature | undefined; getCreature: (id: CreatureId) => AnyCreature | undefined;
isLoaded: boolean; isLoaded: boolean;
isSourceCached: (sourceCode: string) => Promise<boolean>; isSourceCached: (sourceCode: string) => Promise<boolean>;
fetchAndCacheSource: ( fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
sourceCode: string,
url: string,
) => Promise<{ skippedNames: string[] }>;
uploadAndCacheSource: ( uploadAndCacheSource: (
sourceCode: string, sourceCode: string,
jsonData: unknown, jsonData: unknown,
@@ -40,108 +36,6 @@ interface BestiaryHook {
refreshCache: () => Promise<void>; refreshCache: () => Promise<void>;
} }
interface BatchResult {
readonly responses: unknown[];
readonly failed: string[];
}
async function fetchJson(url: string, path: string): Promise<unknown> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch ${path}: ${response.status} ${response.statusText}`,
);
}
return response.json();
}
async function fetchWithRetry(
url: string,
path: string,
retries = 2,
): Promise<unknown> {
try {
return await fetchJson(url, path);
} catch (error) {
if (retries <= 0) throw error;
await new Promise<void>((r) => setTimeout(r, 500));
return fetchWithRetry(url, path, retries - 1);
}
}
async function fetchBatch(
baseUrl: string,
paths: string[],
): Promise<BatchResult> {
const settled = await Promise.allSettled(
paths.map((path) => fetchWithRetry(`${baseUrl}${path}`, path)),
);
const responses: unknown[] = [];
const failed: string[] = [];
for (let i = 0; i < settled.length; i++) {
const result = settled[i];
if (result.status === "fulfilled") {
responses.push(result.value);
} else {
failed.push(paths[i]);
}
}
return { responses, failed };
}
async function fetchInBatches(
paths: string[],
baseUrl: string,
concurrency: number,
): Promise<BatchResult> {
const batches: string[][] = [];
for (let i = 0; i < paths.length; i += concurrency) {
batches.push(paths.slice(i, i + concurrency));
}
const accumulated = await batches.reduce<Promise<BatchResult>>(
async (prev, batch) => {
const acc = await prev;
const result = await fetchBatch(baseUrl, batch);
return {
responses: [...acc.responses, ...result.responses],
failed: [...acc.failed, ...result.failed],
};
},
Promise.resolve({ responses: [], failed: [] }),
);
return accumulated;
}
interface Pf2eFetchResult {
creatures: AnyCreature[];
skippedNames: string[];
}
async function fetchPf2eSource(
paths: string[],
url: string,
sourceCode: string,
displayName: string,
resolveNames: (failedPaths: string[]) => Map<string, string>,
): Promise<Pf2eFetchResult> {
const baseUrl = url.endsWith("/") ? url : `${url}/`;
const { responses, failed } = await fetchInBatches(paths, baseUrl, 6);
if (responses.length === 0) {
throw new Error(
`Failed to fetch any creatures (${failed.length} failed). This may be caused by an ad blocker — try disabling it for this site or use file upload instead.`,
);
}
const nameMap = failed.length > 0 ? resolveNames(failed) : new Map();
const skippedNames = failed.map((p) => nameMap.get(p) ?? p);
if (skippedNames.length > 0) {
console.warn("Skipped creatures (ad blocker?):", skippedNames);
}
return {
creatures: normalizeFoundryCreatures(responses, sourceCode, displayName),
skippedNames,
};
}
export function useBestiary(): BestiaryHook { export function useBestiary(): BestiaryHook {
const { bestiaryCache, bestiaryIndex, pf2eBestiaryIndex } = useAdapters(); const { bestiaryCache, bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
const { edition } = useRulesEditionContext(); const { edition } = useRulesEditionContext();
@@ -161,11 +55,7 @@ export function useBestiary(): BestiaryHook {
} }
void bestiaryCache.loadAllCachedCreatures().then((map) => { void bestiaryCache.loadAllCachedCreatures().then((map) => {
const merged = new Map(map); setCreatureMap(map);
for (const c of loadBundledDndCreatures()) {
merged.set(c.id, c);
}
setCreatureMap(merged);
}); });
}, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]); }, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
@@ -218,25 +108,30 @@ export function useBestiary(): BestiaryHook {
); );
const fetchAndCacheSource = useCallback( const fetchAndCacheSource = useCallback(
async ( async (sourceCode: string, url: string): Promise<void> => {
sourceCode: string,
url: string,
): Promise<{ skippedNames: string[] }> => {
let creatures: AnyCreature[]; let creatures: AnyCreature[];
let skippedNames: string[] = [];
if (edition === "pf2e") { if (edition === "pf2e") {
// PF2e: url is a base URL; fetch each creature file in parallel
const paths = pf2eBestiaryIndex.getCreaturePathsForSource(sourceCode); const paths = pf2eBestiaryIndex.getCreaturePathsForSource(sourceCode);
const baseUrl = url.endsWith("/") ? url : `${url}/`;
const responses = await Promise.all(
paths.map(async (path) => {
const response = await fetch(`${baseUrl}${path}`);
if (!response.ok) {
throw new Error(
`Failed to fetch ${path}: ${response.status} ${response.statusText}`,
);
}
return response.json();
}),
);
const displayName = pf2eBestiaryIndex.getSourceDisplayName(sourceCode); const displayName = pf2eBestiaryIndex.getSourceDisplayName(sourceCode);
const result = await fetchPf2eSource( creatures = normalizeFoundryCreatures(
paths, responses,
url,
sourceCode, sourceCode,
displayName, displayName,
pf2eBestiaryIndex.getCreatureNamesByPaths,
); );
creatures = result.creatures;
skippedNames = result.skippedNames;
} else { } else {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
@@ -265,7 +160,6 @@ export function useBestiary(): BestiaryHook {
} }
return next; return next;
}); });
return { skippedNames };
}, },
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system], [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
); );
@@ -305,9 +199,6 @@ export function useBestiary(): BestiaryHook {
const refreshCache = useCallback(async (): Promise<void> => { const refreshCache = useCallback(async (): Promise<void> => {
const map = await bestiaryCache.loadAllCachedCreatures(); const map = await bestiaryCache.loadAllCachedCreatures();
for (const c of loadBundledDndCreatures()) {
map.set(c.id, c);
}
setCreatureMap(map); setCreatureMap(map);
}, [bestiaryCache]); }, [bestiaryCache]);
+2 -8
View File
@@ -22,10 +22,7 @@ interface BulkImportHook {
state: BulkImportState; state: BulkImportState;
startImport: ( startImport: (
baseUrl: string, baseUrl: string,
fetchAndCacheSource: ( fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
sourceCode: string,
url: string,
) => Promise<{ skippedNames: string[] }>,
isSourceCached: (sourceCode: string) => Promise<boolean>, isSourceCached: (sourceCode: string) => Promise<boolean>,
refreshCache: () => Promise<void>, refreshCache: () => Promise<void>,
) => void; ) => void;
@@ -42,10 +39,7 @@ export function useBulkImport(): BulkImportHook {
const startImport = useCallback( const startImport = useCallback(
( (
baseUrl: string, baseUrl: string,
fetchAndCacheSource: ( fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
sourceCode: string,
url: string,
) => Promise<{ skippedNames: string[] }>,
isSourceCached: (sourceCode: string) => Promise<boolean>, isSourceCached: (sourceCode: string) => Promise<boolean>,
refreshCache: () => Promise<void>, refreshCache: () => Promise<void>,
) => { ) => {
+13 -112
View File
@@ -1,17 +1,11 @@
import type { import type {
AnyCreature,
Combatant, Combatant,
CreatureId, CreatureId,
DifficultyThreshold, DifficultyThreshold,
DifficultyTier, DifficultyTier,
PlayerCharacter, PlayerCharacter,
} from "@initiative/domain"; } from "@initiative/domain";
import { import { calculateEncounterDifficulty, crToXp } from "@initiative/domain";
calculateEncounterDifficulty,
crToXp,
derivePartyLevel,
pf2eCreatureXp,
} from "@initiative/domain";
import { useMemo } from "react"; import { useMemo } from "react";
import { useBestiaryContext } from "../contexts/bestiary-context.js"; import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js"; import { useEncounterContext } from "../contexts/encounter-context.js";
@@ -27,10 +21,6 @@ export interface BreakdownCombatant {
readonly editable: boolean; readonly editable: boolean;
readonly side: "party" | "enemy"; readonly side: "party" | "enemy";
readonly level: number | undefined; readonly level: number | undefined;
/** PF2e only: the creature's level from bestiary data. */
readonly creatureLevel: number | undefined;
/** PF2e only: creature level minus party level. */
readonly levelDifference: number | undefined;
} }
interface DifficultyBreakdown { interface DifficultyBreakdown {
@@ -40,7 +30,6 @@ interface DifficultyBreakdown {
readonly encounterMultiplier: number | undefined; readonly encounterMultiplier: number | undefined;
readonly adjustedXp: number | undefined; readonly adjustedXp: number | undefined;
readonly partySizeAdjusted: boolean | undefined; readonly partySizeAdjusted: boolean | undefined;
readonly partyLevel: number | undefined;
readonly pcCount: number; readonly pcCount: number;
readonly partyCombatants: readonly BreakdownCombatant[]; readonly partyCombatants: readonly BreakdownCombatant[];
readonly enemyCombatants: readonly BreakdownCombatant[]; readonly enemyCombatants: readonly BreakdownCombatant[];
@@ -59,16 +48,9 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
const hasPartyLevel = descriptors.some( const hasPartyLevel = descriptors.some(
(d) => d.side === "party" && d.level !== undefined, (d) => d.side === "party" && d.level !== undefined,
); );
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); const hasCr = descriptors.some((d) => d.cr !== undefined);
if (!hasPartyLevel || !hasCr) return null; if (!hasPartyLevel || !hasCr) return null;
}
const result = calculateEncounterDifficulty(descriptors, edition); const result = calculateEncounterDifficulty(descriptors, edition);
@@ -83,7 +65,6 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
type CreatureInfo = { type CreatureInfo = {
cr?: string; cr?: string;
creatureLevel?: number;
source: string; source: string;
sourceDisplayName: string; sourceDisplayName: string;
}; };
@@ -93,7 +74,6 @@ function buildBreakdownEntry(
side: "party" | "enemy", side: "party" | "enemy",
level: number | undefined, level: number | undefined,
creature: CreatureInfo | undefined, creature: CreatureInfo | undefined,
partyLevel: number | undefined,
): BreakdownCombatant { ): BreakdownCombatant {
if (c.playerCharacterId) { if (c.playerCharacterId) {
return { return {
@@ -104,29 +84,6 @@ function buildBreakdownEntry(
editable: false, editable: false,
side, side,
level, level,
creatureLevel: undefined,
levelDifference: undefined,
};
}
if (creature && creature.creatureLevel !== undefined) {
const levelDiff =
partyLevel === undefined
? undefined
: creature.creatureLevel - partyLevel;
const xp =
partyLevel === undefined
? null
: pf2eCreatureXp(creature.creatureLevel, partyLevel);
return {
combatant: c,
cr: null,
xp,
source: creature.sourceDisplayName ?? creature.source,
editable: false,
side,
level: undefined,
creatureLevel: creature.creatureLevel,
levelDifference: levelDiff,
}; };
} }
if (creature) { if (creature) {
@@ -139,8 +96,6 @@ function buildBreakdownEntry(
editable: false, editable: false,
side, side,
level: undefined, level: undefined,
creatureLevel: undefined,
levelDifference: undefined,
}; };
} }
if (c.cr) { if (c.cr) {
@@ -152,8 +107,6 @@ function buildBreakdownEntry(
editable: true, editable: true,
side, side,
level: undefined, level: undefined,
creatureLevel: undefined,
levelDifference: undefined,
}; };
} }
return { return {
@@ -164,8 +117,6 @@ function buildBreakdownEntry(
editable: !c.creatureId, editable: !c.creatureId,
side, side,
level: undefined, level: undefined,
creatureLevel: undefined,
levelDifference: undefined,
}; };
} }
@@ -177,91 +128,41 @@ function resolveLevel(
return characters.find((p) => p.id === c.playerCharacterId)?.level; return characters.find((p) => p.id === c.playerCharacterId)?.level;
} }
function resolveCreatureInfo( function resolveCr(
c: Combatant, c: Combatant,
getCreature: (id: CreatureId) => AnyCreature | undefined, getCreature: (id: CreatureId) => CreatureInfo | undefined,
): { ): { cr: string | null; creature: CreatureInfo | undefined } {
cr: string | null; const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
creatureLevel: number | undefined; const cr = creature?.cr ?? c.cr ?? null;
creature: CreatureInfo | undefined; return { cr, creature };
} {
const rawCreature = c.creatureId ? getCreature(c.creatureId) : undefined;
if (!rawCreature) {
return {
cr: c.cr ?? null,
creatureLevel: undefined,
creature: undefined,
};
}
if ("system" in rawCreature && rawCreature.system === "pf2e") {
return {
cr: null,
creatureLevel: rawCreature.level,
creature: {
creatureLevel: rawCreature.level,
source: rawCreature.source,
sourceDisplayName: rawCreature.sourceDisplayName,
},
};
}
const cr = "cr" in rawCreature ? rawCreature.cr : undefined;
return {
cr: cr ?? c.cr ?? null,
creatureLevel: undefined,
creature: {
cr,
source: rawCreature.source,
sourceDisplayName: rawCreature.sourceDisplayName,
},
};
}
function collectPartyLevel(
combatants: readonly Combatant[],
characters: readonly PlayerCharacter[],
): number | undefined {
const partyLevels: number[] = [];
for (const c of combatants) {
if (resolveSide(c) !== "party") continue;
const level = resolveLevel(c, characters);
if (level !== undefined) partyLevels.push(level);
}
return partyLevels.length > 0 ? derivePartyLevel(partyLevels) : undefined;
} }
function classifyCombatants( function classifyCombatants(
combatants: readonly Combatant[], combatants: readonly Combatant[],
characters: readonly PlayerCharacter[], characters: readonly PlayerCharacter[],
getCreature: (id: CreatureId) => AnyCreature | undefined, getCreature: (id: CreatureId) => CreatureInfo | undefined,
) { ) {
const partyCombatants: BreakdownCombatant[] = []; const partyCombatants: BreakdownCombatant[] = [];
const enemyCombatants: BreakdownCombatant[] = []; const enemyCombatants: BreakdownCombatant[] = [];
const descriptors: { const descriptors: {
level?: number; level?: number;
cr?: string; cr?: string;
creatureLevel?: number;
side: "party" | "enemy"; side: "party" | "enemy";
}[] = []; }[] = [];
let pcCount = 0; let pcCount = 0;
const partyLevel = collectPartyLevel(combatants, characters);
for (const c of combatants) { for (const c of combatants) {
const side = resolveSide(c); const side = resolveSide(c);
const level = resolveLevel(c, characters); const level = resolveLevel(c, characters);
if (level !== undefined) pcCount++; if (level !== undefined) pcCount++;
const { cr, creatureLevel, creature } = resolveCreatureInfo(c, getCreature); const { cr, creature } = resolveCr(c, getCreature);
if (level !== undefined || cr != null || creatureLevel !== undefined) { if (level !== undefined || cr != null) {
descriptors.push({ descriptors.push({ level, cr: cr ?? undefined, side });
level,
cr: cr ?? undefined,
creatureLevel,
side,
});
} }
const entry = buildBreakdownEntry(c, side, level, creature, partyLevel); const entry = buildBreakdownEntry(c, side, level, creature);
const target = side === "party" ? partyCombatants : enemyCombatants; const target = side === "party" ? partyCombatants : enemyCombatants;
target.push(entry); target.push(entry);
} }
+5 -18
View File
@@ -33,17 +33,9 @@ function buildDescriptors(
const creatureCr = const creatureCr =
creature && !("system" in creature) ? creature.cr : undefined; creature && !("system" in creature) ? creature.cr : undefined;
const cr = creatureCr ?? c.cr ?? undefined; const cr = creatureCr ?? c.cr ?? undefined;
const creatureLevel =
creature && "system" in creature && creature.system === "pf2e"
? creature.level
: undefined;
if ( if (level !== undefined || cr !== undefined) {
level !== undefined || descriptors.push({ level, cr, side });
cr !== undefined ||
creatureLevel !== undefined
) {
descriptors.push({ level, cr, creatureLevel, side });
} }
} }
return descriptors; return descriptors;
@@ -56,6 +48,8 @@ export function useDifficulty(): DifficultyResult | null {
const { edition } = useRulesEditionContext(); const { edition } = useRulesEditionContext();
return useMemo(() => { return useMemo(() => {
if (edition === "pf2e") return null;
const descriptors = buildDescriptors( const descriptors = buildDescriptors(
encounter.combatants, encounter.combatants,
characters, characters,
@@ -65,16 +59,9 @@ export function useDifficulty(): DifficultyResult | null {
const hasPartyLevel = descriptors.some( const hasPartyLevel = descriptors.some(
(d) => d.side === "party" && d.level !== undefined, (d) => d.side === "party" && d.level !== undefined,
); );
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); const hasCr = descriptors.some((d) => d.cr !== undefined);
if (!hasPartyLevel || !hasCr) return null; if (!hasPartyLevel || !hasCr) return null;
}
return calculateEncounterDifficulty(descriptors, edition); return calculateEncounterDifficulty(descriptors, edition);
}, [encounter.combatants, characters, getCreature, edition]); }, [encounter.combatants, characters, getCreature, edition]);
-164
View File
@@ -1,7 +1,6 @@
import type { EncounterStore, UndoRedoStore } from "@initiative/application"; import type { EncounterStore, UndoRedoStore } from "@initiative/application";
import { import {
addCombatantUseCase, addCombatantUseCase,
addPersistentDamageUseCase,
adjustHpUseCase, adjustHpUseCase,
advanceTurnUseCase, advanceTurnUseCase,
clearEncounterUseCase, clearEncounterUseCase,
@@ -9,13 +8,11 @@ import {
editCombatantUseCase, editCombatantUseCase,
redoUseCase, redoUseCase,
removeCombatantUseCase, removeCombatantUseCase,
removePersistentDamageUseCase,
retreatTurnUseCase, retreatTurnUseCase,
setAcUseCase, setAcUseCase,
setConditionValueUseCase, setConditionValueUseCase,
setCrUseCase, setCrUseCase,
setHpUseCase, setHpUseCase,
setHpVariantUseCase,
setInitiativeUseCase, setInitiativeUseCase,
setSideUseCase, setSideUseCase,
setTempHpUseCase, setTempHpUseCase,
@@ -31,18 +28,12 @@ import type {
DomainError, DomainError,
DomainEvent, DomainEvent,
Encounter, Encounter,
HpRange,
HpVariant,
PersistentDamageType,
Pf2eCreature,
PlayerCharacter, PlayerCharacter,
UndoRedoState, UndoRedoState,
} from "@initiative/domain"; } from "@initiative/domain";
import { import {
acDelta,
clearHistory, clearHistory,
combatantId, combatantId,
hpDelta,
isDomainError, isDomainError,
creatureId as makeCreatureId, creatureId as makeCreatureId,
pushUndo, pushUndo,
@@ -62,12 +53,6 @@ type EncounterAction =
| { type: "edit-combatant"; id: CombatantId; newName: string } | { type: "edit-combatant"; id: CombatantId; newName: string }
| { type: "set-initiative"; id: CombatantId; value: number | undefined } | { type: "set-initiative"; id: CombatantId; value: number | undefined }
| { type: "set-hp"; id: CombatantId; maxHp: number | undefined } | { type: "set-hp"; id: CombatantId; maxHp: number | undefined }
| {
type: "set-hp-variant";
id: CombatantId;
variant: HpVariant | undefined;
range: HpRange;
}
| { type: "adjust-hp"; id: CombatantId; delta: number } | { type: "adjust-hp"; id: CombatantId; delta: number }
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined } | { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
| { type: "set-ac"; id: CombatantId; value: number | undefined } | { type: "set-ac"; id: CombatantId; value: number | undefined }
@@ -90,17 +75,6 @@ type EncounterAction =
conditionId: ConditionId; conditionId: ConditionId;
} }
| { type: "toggle-concentration"; id: CombatantId } | { type: "toggle-concentration"; id: CombatantId }
| {
type: "add-persistent-damage";
id: CombatantId;
damageType: PersistentDamageType;
formula: string;
}
| {
type: "remove-persistent-damage";
id: CombatantId;
damageType: PersistentDamageType;
}
| { type: "clear-encounter" } | { type: "clear-encounter" }
| { type: "undo" } | { type: "undo" }
| { type: "redo" } | { type: "redo" }
@@ -110,12 +84,6 @@ type EncounterAction =
entry: SearchResult; entry: SearchResult;
count: number; count: number;
} }
| {
type: "set-creature-adjustment";
id: CombatantId;
adjustment: "weak" | "elite" | undefined;
baseCreature: Pf2eCreature;
}
| { type: "add-from-player-character"; pc: PlayerCharacter } | { type: "add-from-player-character"; pc: PlayerCharacter }
| { | {
type: "import"; type: "import";
@@ -311,76 +279,6 @@ function handleAddFromPlayerCharacter(
}; };
} }
function applyNamePrefix(
name: string,
oldAdj: "weak" | "elite" | undefined,
newAdj: "weak" | "elite" | undefined,
): string {
let base = name;
if (oldAdj === "weak" && name.startsWith("Weak ")) base = name.slice(5);
else if (oldAdj === "elite" && name.startsWith("Elite "))
base = name.slice(6);
if (newAdj === "weak") return `Weak ${base}`;
if (newAdj === "elite") return `Elite ${base}`;
return base;
}
function handleSetCreatureAdjustment(
state: EncounterState,
id: CombatantId,
adjustment: "weak" | "elite" | undefined,
baseCreature: Pf2eCreature,
): EncounterState {
const combatant = state.encounter.combatants.find((c) => c.id === id);
if (!combatant) return state;
const oldAdj = combatant.creatureAdjustment;
if (oldAdj === adjustment) return state;
const baseLevel = baseCreature.level;
const oldHpDelta = oldAdj ? hpDelta(baseLevel, oldAdj) : 0;
const newHpDelta = adjustment ? hpDelta(baseLevel, adjustment) : 0;
const netHpDelta = newHpDelta - oldHpDelta;
const oldAcDelta = oldAdj ? acDelta(oldAdj) : 0;
const newAcDelta = adjustment ? acDelta(adjustment) : 0;
const netAcDelta = newAcDelta - oldAcDelta;
const newMaxHp =
combatant.maxHp === undefined ? undefined : combatant.maxHp + netHpDelta;
const newCurrentHp =
combatant.currentHp === undefined || newMaxHp === undefined
? undefined
: Math.max(0, Math.min(combatant.currentHp + netHpDelta, newMaxHp));
const newAc =
combatant.ac === undefined ? undefined : combatant.ac + netAcDelta;
const newName = applyNamePrefix(combatant.name, oldAdj, adjustment);
const updatedCombatant: typeof combatant = {
...combatant,
name: newName,
...(newMaxHp !== undefined && { maxHp: newMaxHp }),
...(newCurrentHp !== undefined && { currentHp: newCurrentHp }),
...(newAc !== undefined && { ac: newAc }),
...(adjustment === undefined
? { creatureAdjustment: undefined }
: { creatureAdjustment: adjustment }),
};
const combatants = state.encounter.combatants.map((c) =>
c.id === id ? updatedCombatant : c,
);
return {
...state,
encounter: { ...state.encounter, combatants },
events: [
...state.events,
{ type: "CreatureAdjustmentSet", combatantId: id, adjustment },
],
};
}
// -- Reducer -- // -- Reducer --
export function encounterReducer( export function encounterReducer(
@@ -412,13 +310,6 @@ export function encounterReducer(
lastCreatureId: null, lastCreatureId: null,
}; };
} }
case "set-creature-adjustment":
return handleSetCreatureAdjustment(
state,
action.id,
action.adjustment,
action.baseCreature,
);
case "add-from-bestiary": case "add-from-bestiary":
return handleAddFromBestiary(state, action.entry, 1); return handleAddFromBestiary(state, action.entry, 1);
case "add-multiple-from-bestiary": case "add-multiple-from-bestiary":
@@ -441,7 +332,6 @@ function dispatchEncounterAction(
| { type: "edit-combatant" } | { type: "edit-combatant" }
| { type: "set-initiative" } | { type: "set-initiative" }
| { type: "set-hp" } | { type: "set-hp" }
| { type: "set-hp-variant" }
| { type: "adjust-hp" } | { type: "adjust-hp" }
| { type: "set-temp-hp" } | { type: "set-temp-hp" }
| { type: "set-ac" } | { type: "set-ac" }
@@ -451,8 +341,6 @@ function dispatchEncounterAction(
| { type: "set-condition-value" } | { type: "set-condition-value" }
| { type: "decrement-condition" } | { type: "decrement-condition" }
| { type: "toggle-concentration" } | { type: "toggle-concentration" }
| { type: "add-persistent-damage" }
| { type: "remove-persistent-damage" }
>, >,
): EncounterState { ): EncounterState {
const { store, getEncounter } = makeStoreFromState(state); const { store, getEncounter } = makeStoreFromState(state);
@@ -482,14 +370,6 @@ function dispatchEncounterAction(
case "set-hp": case "set-hp":
result = setHpUseCase(store, action.id, action.maxHp); result = setHpUseCase(store, action.id, action.maxHp);
break; break;
case "set-hp-variant":
result = setHpVariantUseCase(
store,
action.id,
action.variant,
action.range,
);
break;
case "adjust-hp": case "adjust-hp":
result = adjustHpUseCase(store, action.id, action.delta); result = adjustHpUseCase(store, action.id, action.delta);
break; break;
@@ -522,21 +402,6 @@ function dispatchEncounterAction(
case "toggle-concentration": case "toggle-concentration":
result = toggleConcentrationUseCase(store, action.id); result = toggleConcentrationUseCase(store, action.id);
break; break;
case "add-persistent-damage":
result = addPersistentDamageUseCase(
store,
action.id,
action.damageType,
action.formula,
);
break;
case "remove-persistent-damage":
result = removePersistentDamageUseCase(
store,
action.id,
action.damageType,
);
break;
} }
if (isDomainError(result)) return state; if (isDomainError(result)) return state;
@@ -700,35 +565,6 @@ export function useEncounter() {
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }), (id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
[], [],
), ),
addPersistentDamage: useCallback(
(id: CombatantId, damageType: PersistentDamageType, formula: string) =>
dispatch({ type: "add-persistent-damage", id, damageType, formula }),
[],
),
removePersistentDamage: useCallback(
(id: CombatantId, damageType: PersistentDamageType) =>
dispatch({ type: "remove-persistent-damage", id, damageType }),
[],
),
setCreatureAdjustment: useCallback(
(
id: CombatantId,
adjustment: "weak" | "elite" | undefined,
baseCreature: Pf2eCreature,
) =>
dispatch({
type: "set-creature-adjustment",
id,
adjustment,
baseCreature,
}),
[],
),
setHpVariant: useCallback(
(id: CombatantId, variant: HpVariant | undefined, range: HpRange) =>
dispatch({ type: "set-hp-variant", id, variant, range }),
[],
),
clearEncounter: useCallback( clearEncounter: useCallback(
() => dispatch({ type: "clear-encounter" }), () => dispatch({ type: "clear-encounter" }),
[], [],
+4 -12
View File
@@ -9,18 +9,10 @@ import { isDomainError, playerCharacterId } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useAdapters } from "../contexts/adapter-context.js"; import { useAdapters } from "../contexts/adapter-context.js";
const PC_ID_PATTERN = /^pc-(\d+)$/; let nextPcId = 0;
function generatePcId(existing: readonly PlayerCharacter[]): PlayerCharacterId { function generatePcId(): PlayerCharacterId {
let max = 0; return playerCharacterId(`pc-${++nextPcId}`);
for (const pc of existing) {
const match = PC_ID_PATTERN.exec(pc.id);
if (match) {
const n = Number(match[1]);
if (n > max) max = n;
}
}
return playerCharacterId(`pc-${max + 1}`);
} }
interface EditFields { interface EditFields {
@@ -63,7 +55,7 @@ export function usePlayerCharacters() {
icon: string | undefined, icon: string | undefined,
level: number | undefined, level: number | undefined,
) => { ) => {
const id = generatePcId(charactersRef.current); const id = generatePcId();
const result = createPlayerCharacterUseCase( const result = createPlayerCharacterUseCase(
makeStore(), makeStore(),
id, id,
+10 -21
View File
@@ -1,16 +1,15 @@
import type { CombatantId, CreatureId } from "@initiative/domain"; import type { CreatureId } from "@initiative/domain";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
type PanelView = type PanelView =
| { mode: "closed" } | { mode: "closed" }
| { mode: "creature"; creatureId: CreatureId; combatantId?: CombatantId } | { mode: "creature"; creatureId: CreatureId }
| { mode: "bulk-import" } | { mode: "bulk-import" }
| { mode: "source-manager" }; | { mode: "source-manager" };
interface SidePanelState { interface SidePanelState {
panelView: PanelView; panelView: PanelView;
selectedCreatureId: CreatureId | null; selectedCreatureId: CreatureId | null;
selectedCombatantId: CombatantId | null;
bulkImportMode: boolean; bulkImportMode: boolean;
sourceManagerMode: boolean; sourceManagerMode: boolean;
isRightPanelCollapsed: boolean; isRightPanelCollapsed: boolean;
@@ -19,8 +18,8 @@ interface SidePanelState {
} }
interface SidePanelActions { interface SidePanelActions {
showCreature: (creatureId: CreatureId, combatantId?: CombatantId) => void; showCreature: (creatureId: CreatureId) => void;
updateCreature: (creatureId: CreatureId, combatantId?: CombatantId) => void; updateCreature: (creatureId: CreatureId) => void;
showBulkImport: () => void; showBulkImport: () => void;
showSourceManager: () => void; showSourceManager: () => void;
dismissPanel: () => void; dismissPanel: () => void;
@@ -49,23 +48,14 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
const selectedCreatureId = const selectedCreatureId =
panelView.mode === "creature" ? panelView.creatureId : null; panelView.mode === "creature" ? panelView.creatureId : null;
const selectedCombatantId = const showCreature = useCallback((creatureId: CreatureId) => {
panelView.mode === "creature" ? (panelView.combatantId ?? null) : null; setPanelView({ mode: "creature", creatureId });
const showCreature = useCallback(
(creatureId: CreatureId, combatantId?: CombatantId) => {
setPanelView({ mode: "creature", creatureId, combatantId });
setIsRightPanelCollapsed(false); setIsRightPanelCollapsed(false);
}, }, []);
[],
);
const updateCreature = useCallback( const updateCreature = useCallback((creatureId: CreatureId) => {
(creatureId: CreatureId, combatantId?: CombatantId) => { setPanelView({ mode: "creature", creatureId });
setPanelView({ mode: "creature", creatureId, combatantId }); }, []);
},
[],
);
const showBulkImport = useCallback(() => { const showBulkImport = useCallback(() => {
setPanelView({ mode: "bulk-import" }); setPanelView({ mode: "bulk-import" });
@@ -100,7 +90,6 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
return { return {
panelView, panelView,
selectedCreatureId, selectedCreatureId,
selectedCombatantId,
bulkImportMode: panelView.mode === "bulk-import", bulkImportMode: panelView.mode === "bulk-import",
sourceManagerMode: panelView.mode === "source-manager", sourceManagerMode: panelView.mode === "source-manager",
isRightPanelCollapsed, isRightPanelCollapsed,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"$schema": "https://unpkg.com/knip@6/schema.json", "$schema": "https://unpkg.com/knip@5/schema.json",
"workspaces": { "workspaces": {
".": { ".": {
"entry": ["scripts/*.mjs"] "entry": ["scripts/*.mjs"]
+3 -7
View File
@@ -3,12 +3,8 @@ pre-commit:
jobs: jobs:
- name: audit - name: audit
run: pnpm audit --audit-level=high run: pnpm audit --audit-level=high
# Must go through the npm script, not `pnpm exec knip`: the script sets
# KNIP_DISABLE_RAW_TRANSFER=1. Knip 6's default oxc-parser raw-transfer path
# allocates a 6 GiB ArrayBuffer, which Linux refuses under heuristic
# overcommit on the CI runner (3.7 GB RAM, no swap). See issue #11.
- name: knip - name: knip
run: pnpm knip run: pnpm exec knip
- name: biome - name: biome
run: pnpm exec biome check . run: pnpm exec biome check .
- name: check-ignores - name: check-ignores
@@ -18,7 +14,7 @@ pre-commit:
- name: check-props - name: check-props
run: node scripts/check-component-props.mjs run: node scripts/check-component-props.mjs
- name: jscpd - name: jscpd
run: pnpm jscpd run: pnpm exec jscpd
- name: jsinspect - name: jsinspect
run: pnpm jsinspect run: pnpm jsinspect
- name: typecheck-oxlint-test - name: typecheck-oxlint-test
@@ -28,6 +24,6 @@ pre-commit:
- name: typecheck - name: typecheck
run: pnpm exec tsc --build run: pnpm exec tsc --build
- name: oxlint - name: oxlint
run: pnpm oxlint run: pnpm oxlint -- --deny warnings
- name: test - name: test
run: pnpm vitest run --reporter=dot --coverage.reporter=text-summary run: pnpm vitest run --reporter=dot --coverage.reporter=text-summary
+11 -5
View File
@@ -1,12 +1,18 @@
{ {
"private": true, "private": true,
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be", "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
"pnpm": {
"overrides": {
"undici": ">=7.24.0",
"picomatch": ">=4.0.4"
}
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.8", "@biomejs/biome": "2.4.8",
"@vitest/coverage-v8": "^4.1.0", "@vitest/coverage-v8": "^4.1.0",
"jscpd": "^4.0.8", "jscpd": "^4.0.8",
"jsinspect-plus": "^3.1.3", "jsinspect-plus": "^3.1.3",
"knip": "^6.31.0", "knip": "^5.88.1",
"lefthook": "^2.1.4", "lefthook": "^2.1.4",
"oxlint": "^1.56.0", "oxlint": "^1.56.0",
"oxlint-tsgolint": "^0.17.1", "oxlint-tsgolint": "^0.17.1",
@@ -22,13 +28,13 @@
"typecheck": "tsc --build", "typecheck": "tsc --build",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"knip": "KNIP_DISABLE_RAW_TRANSFER=1 knip", "knip": "knip",
"jscpd": "jscpd apps/web/src packages/domain/src packages/application/src", "jscpd": "jscpd",
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src", "jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
"oxlint": "oxlint --tsconfig tsconfig.json --type-aware --deny-warnings", "oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny-warnings",
"check:ignores": "node scripts/check-lint-ignores.mjs", "check:ignores": "node scripts/check-lint-ignores.mjs",
"check:classnames": "node scripts/check-cn-classnames.mjs", "check:classnames": "node scripts/check-cn-classnames.mjs",
"check:props": "node scripts/check-component-props.mjs", "check:props": "node scripts/check-component-props.mjs",
"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" "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"
} }
} }
@@ -1,20 +0,0 @@
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),
);
}
-3
View File
@@ -1,5 +1,4 @@
export { addCombatantUseCase } from "./add-combatant-use-case.js"; export { addCombatantUseCase } from "./add-combatant-use-case.js";
export { addPersistentDamageUseCase } from "./add-persistent-damage-use-case.js";
export { adjustHpUseCase } from "./adjust-hp-use-case.js"; export { adjustHpUseCase } from "./adjust-hp-use-case.js";
export { advanceTurnUseCase } from "./advance-turn-use-case.js"; export { advanceTurnUseCase } from "./advance-turn-use-case.js";
export { clearEncounterUseCase } from "./clear-encounter-use-case.js"; export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
@@ -16,7 +15,6 @@ export type {
} from "./ports.js"; } from "./ports.js";
export { redoUseCase } from "./redo-use-case.js"; export { redoUseCase } from "./redo-use-case.js";
export { removeCombatantUseCase } from "./remove-combatant-use-case.js"; export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
export { removePersistentDamageUseCase } from "./remove-persistent-damage-use-case.js";
export { retreatTurnUseCase } from "./retreat-turn-use-case.js"; export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
export { export {
type RollAllResult, type RollAllResult,
@@ -27,7 +25,6 @@ export { setAcUseCase } from "./set-ac-use-case.js";
export { setConditionValueUseCase } from "./set-condition-value-use-case.js"; export { setConditionValueUseCase } from "./set-condition-value-use-case.js";
export { setCrUseCase } from "./set-cr-use-case.js"; export { setCrUseCase } from "./set-cr-use-case.js";
export { setHpUseCase } from "./set-hp-use-case.js"; export { setHpUseCase } from "./set-hp-use-case.js";
export { setHpVariantUseCase } from "./set-hp-variant-use-case.js";
export { setInitiativeUseCase } from "./set-initiative-use-case.js"; export { setInitiativeUseCase } from "./set-initiative-use-case.js";
export { setSideUseCase } from "./set-side-use-case.js"; export { setSideUseCase } from "./set-side-use-case.js";
export { setTempHpUseCase } from "./set-temp-hp-use-case.js"; export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
@@ -1,19 +0,0 @@
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),
);
}
@@ -1,21 +0,0 @@
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,8 +2,6 @@ import { describe, expect, it } from "vitest";
import { import {
calculateEncounterDifficulty, calculateEncounterDifficulty,
crToXp, crToXp,
derivePartyLevel,
pf2eCreatureXp,
} from "../encounter-difficulty.js"; } from "../encounter-difficulty.js";
describe("crToXp", () => { describe("crToXp", () => {
@@ -49,9 +47,9 @@ function enemy(cr: string) {
} }
describe("calculateEncounterDifficulty — 5.5e edition", () => { describe("calculateEncounterDifficulty — 5.5e edition", () => {
it("returns tier 0 when monster XP is 0", () => { it("returns tier 0 when monster XP is below Low threshold", () => {
// 4x level 1: Low = 200, Moderate = 300, High = 400 // 4x level 1: Low = 200, Moderate = 300, High = 400
// 1x CR 0 = 0 XP -> tier 0 (5.5e has no Trivial tier; 0 bars only at 0 XP) // 1x CR 0 = 0 XP -> tier 0
const result = calculateEncounterDifficulty( const result = calculateEncounterDifficulty(
[party(1), party(1), party(1), party(1), enemy("0")], [party(1), party(1), party(1), party(1), enemy("0")],
"5.5e", "5.5e",
@@ -77,29 +75,9 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
expect(result.totalMonsterXp).toBe(200); expect(result.totalMonsterXp).toBe(200);
}); });
it("returns tier 2 when XP spends exactly the Moderate budget", () => { it("returns tier 2 for 5x level 3 vs 1150 XP", () => {
// 5x level 3: Low = 750, Moderate = 1125, High = 2000 // 5x level 3: Low = 750, Moderate = 1125, High = 2000
// CR 4 (1100) + CR 1/8 (25) = 1125 XP = Moderate budget // CR 3 (700) + CR 2 (450) = 1150 XP >= 1125 Moderate
const result = calculateEncounterDifficulty(
[
party(3),
party(3),
party(3),
party(3),
party(3),
enemy("4"),
enemy("1/8"),
],
"5.5e",
);
expect(result.tier).toBe(2);
expect(result.totalMonsterXp).toBe(1125);
expect(result.thresholds[1].value).toBe(1125);
});
it("returns tier 3 when XP exceeds the Moderate budget", () => {
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
// CR 3 (700) + CR 2 (450) = 1150 XP > 1125 Moderate → High
const result = calculateEncounterDifficulty( const result = calculateEncounterDifficulty(
[ [
party(3), party(3),
@@ -112,24 +90,14 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
], ],
"5.5e", "5.5e",
); );
expect(result.tier).toBe(3); expect(result.tier).toBe(2);
expect(result.totalMonsterXp).toBe(1150); expect(result.totalMonsterXp).toBe(1150);
expect(result.thresholds[1].value).toBe(1125);
}); });
it("returns tier 3 for 5x level 5 vs 5000 XP (over Moderate budget)", () => { it("returns tier 3 when XP meets High threshold", () => {
// 5x level 5: Low = 2500, Moderate = 3750, High = 5500 // 4x level 1: High = 400
// CR 9 = 5000 XP > 3750 Moderate → High, even though below the High budget // 2x CR 1 = 400 XP -> tier 3
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), party(5), enemy("9")],
"5.5e",
);
expect(result.tier).toBe(3);
expect(result.totalMonsterXp).toBe(5000);
});
it("returns tier 3 when XP exceeds the Moderate budget", () => {
// 4x level 1: Moderate = 300
// 2x CR 1 = 400 XP > 300 -> tier 3
const result = calculateEncounterDifficulty( const result = calculateEncounterDifficulty(
[party(1), party(1), party(1), party(1), enemy("1"), enemy("1")], [party(1), party(1), party(1), party(1), enemy("1"), enemy("1")],
"5.5e", "5.5e",
@@ -150,7 +118,6 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
it("handles mixed party levels", () => { it("handles mixed party levels", () => {
// 3x level 3 + 1x level 2 // 3x level 3 + 1x level 2
// Total: low=550, mod=825, high=1400 // Total: low=550, mod=825, high=1400
// 700 XP > 550 Low, ≤ 825 Moderate → Moderate
const result = calculateEncounterDifficulty( const result = calculateEncounterDifficulty(
[party(3), party(3), party(3), party(2), enemy("3")], [party(3), party(3), party(3), party(2), enemy("3")],
"5.5e", "5.5e",
@@ -161,7 +128,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
{ label: "High", value: 1400 }, { label: "High", value: 1400 },
]); ]);
expect(result.totalMonsterXp).toBe(700); expect(result.totalMonsterXp).toBe(700);
expect(result.tier).toBe(2); expect(result.tier).toBe(1);
}); });
it("returns tier 0 with no enemies", () => { it("returns tier 0 with no enemies", () => {
@@ -195,7 +162,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
"5.5e", "5.5e",
); );
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100 expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
expect(result.tier).toBe(1); // 175 200 Low budget → Low expect(result.tier).toBe(0); // 175 < 200 Low
}); });
it("ignores unknown CRs (0 XP)", () => { it("ignores unknown CRs (0 XP)", () => {
@@ -222,7 +189,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
"5.5e", "5.5e",
); );
expect(result.totalMonsterXp).toBe(250); expect(result.totalMonsterXp).toBe(250);
expect(result.tier).toBe(2); // 250 > 200 Low budget, 300 Moderate → Moderate expect(result.tier).toBe(1); // 250 >= 200 Low, < 300 Moderate
}); });
it("floors net monster XP at 0", () => { it("floors net monster XP at 0", () => {
@@ -285,7 +252,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
{ label: "High", value: 800 }, { label: "High", value: 800 },
]); ]);
expect(result.totalMonsterXp).toBe(700); expect(result.totalMonsterXp).toBe(700);
expect(result.tier).toBe(3); // 700 > 450 Moderate budget → High expect(result.tier).toBe(2); // 700 >= 450 Moderate, < 800 High
}); });
}); });
@@ -419,263 +386,3 @@ describe("calculateEncounterDifficulty — 2014 edition", () => {
expect(result.adjustedXp).toBeUndefined(); expect(result.adjustedXp).toBeUndefined();
}); });
}); });
/** Helper to build a PF2e enemy-side descriptor with creature level. */
function pf2eEnemy(creatureLevel: number) {
return { creatureLevel, side: "enemy" as const };
}
/** Helper to build a PF2e party-side creature descriptor. */
function pf2eAlly(creatureLevel: number) {
return { creatureLevel, side: "party" as const };
}
describe("derivePartyLevel", () => {
it("returns 0 for empty array", () => {
expect(derivePartyLevel([])).toBe(0);
});
it("returns the level for a single PC", () => {
expect(derivePartyLevel([7])).toBe(7);
});
it("returns the unanimous level", () => {
expect(derivePartyLevel([5, 5, 5, 5])).toBe(5);
});
it("returns the mode when one level is most common", () => {
expect(derivePartyLevel([3, 3, 3, 5])).toBe(3);
});
it("returns rounded average when mode is tied", () => {
// 3,3,5,5 → average 4
expect(derivePartyLevel([3, 3, 5, 5])).toBe(4);
});
it("returns rounded average when all levels are different", () => {
// 2,4,6,8 → average 5
expect(derivePartyLevel([2, 4, 6, 8])).toBe(5);
});
it("rounds average to nearest integer", () => {
// 1,2 → average 1.5 → rounds to 2
expect(derivePartyLevel([1, 2])).toBe(2);
});
});
describe("pf2eCreatureXp", () => {
it.each([
[-4, 10],
[-3, 15],
[-2, 20],
[-1, 30],
[0, 40],
[1, 60],
[2, 80],
[3, 120],
[4, 160],
])("level diff %i returns %i XP", (diff, expectedXp) => {
// partyLevel 5, creatureLevel = 5 + diff
expect(pf2eCreatureXp(5 + diff, 5)).toBe(expectedXp);
});
it("returns 0 XP for creatures more than 4 levels below the party", () => {
expect(pf2eCreatureXp(0, 10)).toBe(0);
});
it("clamps level diff above +4 to +4 (160 XP)", () => {
expect(pf2eCreatureXp(15, 5)).toBe(160);
});
});
describe("calculateEncounterDifficulty — pf2e edition", () => {
it("returns Trivial (tier 0) for 40 XP with party of 4", () => {
// 1 creature at party level = 40 XP, within the Trivial band (40 or less)
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(5)],
"pf2e",
);
expect(result.tier).toBe(0);
expect(result.totalMonsterXp).toBe(40);
expect(result.partyLevel).toBe(5);
expect(result.thresholds).toEqual([
{ label: "Trivial", value: 40 },
{ label: "Low", value: 60 },
{ label: "Moderate", value: 80 },
{ label: "Severe", value: 120 },
{ label: "Extreme", value: 160 },
]);
});
it("returns Low (tier 1) for 60 XP", () => {
// 1 creature at party level +1 = 60 XP
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(6)],
"pf2e",
);
expect(result.tier).toBe(1);
expect(result.totalMonsterXp).toBe(60);
});
it("returns Moderate (tier 2) for 80 XP", () => {
// 1 creature at +2 = 80 XP
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(7)],
"pf2e",
);
expect(result.tier).toBe(2);
expect(result.totalMonsterXp).toBe(80);
});
it("returns Severe (tier 3) for 120 XP", () => {
// 1 creature at +3 = 120 XP
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(8)],
"pf2e",
);
expect(result.tier).toBe(3);
expect(result.totalMonsterXp).toBe(120);
});
it("returns Extreme (tier 4) for 160 XP", () => {
// 1 creature at +4 = 160 XP > Severe budget (120)
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(9)],
"pf2e",
);
expect(result.tier).toBe(4);
expect(result.totalMonsterXp).toBe(160);
});
it("returns Moderate (tier 2) for XP between the Low and Moderate budgets", () => {
// +1 (60) + 4 (10) = 70 XP > Low (60), ≤ Moderate (80)
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(6), pf2eEnemy(1)],
"pf2e",
);
expect(result.tier).toBe(2);
expect(result.totalMonsterXp).toBe(70);
});
it("returns Extreme (tier 4) for XP just over the Severe budget", () => {
// +3 (120) + 4 (10) = 130 XP > Severe (120)
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(8), pf2eEnemy(1)],
"pf2e",
);
expect(result.tier).toBe(4);
expect(result.totalMonsterXp).toBe(130);
});
it("returns tier 0 when XP is within the Trivial band", () => {
// 1 creature at 4 = 10 XP ≤ Trivial (40)
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(1)],
"pf2e",
);
expect(result.tier).toBe(0);
expect(result.totalMonsterXp).toBe(10);
});
it("counts creatures more than 4 levels below the party as 0 XP", () => {
// Party level 6: a level-1 creature is 5 below → 0 XP
const result = calculateEncounterDifficulty(
[party(6), party(6), party(6), party(6), pf2eEnemy(1), pf2eEnemy(6)],
"pf2e",
);
expect(result.totalMonsterXp).toBe(40); // only the at-level creature counts
});
it("adjusts thresholds for 5 PCs (increases by adjustment)", () => {
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), party(5), pf2eEnemy(5)],
"pf2e",
);
expect(result.thresholds).toEqual([
{ label: "Trivial", value: 50 },
{ label: "Low", value: 80 },
{ label: "Moderate", value: 100 },
{ label: "Severe", value: 150 },
{ label: "Extreme", value: 200 },
]);
});
it("adjusts thresholds for 3 PCs (decreases by adjustment)", () => {
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), pf2eEnemy(5)],
"pf2e",
);
expect(result.thresholds).toEqual([
{ label: "Trivial", value: 30 },
{ label: "Low", value: 40 },
{ label: "Moderate", value: 60 },
{ label: "Severe", value: 90 },
{ label: "Extreme", value: 120 },
]);
});
it("floors thresholds at 0 for very small parties", () => {
const result = calculateEncounterDifficulty(
[party(5), pf2eEnemy(5)],
"pf2e",
);
// 1 PC: adjustment = 3
// Trivial: 40 + (3 * 10) = 10
// Low: 60 + (3 * 20) = 0
expect(result.thresholds[0].value).toBe(10);
expect(result.thresholds[1].value).toBe(0);
expect(result.thresholds[2].value).toBe(20); // 80 60
expect(result.thresholds[3].value).toBe(30); // 120 90
expect(result.thresholds[4].value).toBe(40); // 160 120
});
it("subtracts XP for party-side creatures", () => {
// 2 enemies at party level = 80 XP, 1 ally at party level = 40 XP
// Net = 80 40 = 40 XP
const result = calculateEncounterDifficulty(
[
party(5),
party(5),
party(5),
party(5),
pf2eEnemy(5),
pf2eEnemy(5),
pf2eAlly(5),
],
"pf2e",
);
expect(result.totalMonsterXp).toBe(40);
});
it("floors net creature XP at 0", () => {
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(1), pf2eAlly(9)],
"pf2e",
);
expect(result.totalMonsterXp).toBe(0);
});
it("derives party level using mode", () => {
// 3x level 3, 1x level 5 → mode is 3
const result = calculateEncounterDifficulty(
[party(3), party(3), party(3), party(5), pf2eEnemy(3)],
"pf2e",
);
expect(result.partyLevel).toBe(3);
});
it("has no encounterMultiplier, adjustedXp, or partySizeAdjusted", () => {
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(5)],
"pf2e",
);
expect(result.encounterMultiplier).toBeUndefined();
expect(result.adjustedXp).toBeUndefined();
expect(result.partySizeAdjusted).toBeUndefined();
});
it("returns partyLevel undefined for D&D editions", () => {
const result = calculateEncounterDifficulty([party(1), enemy("1")], "5.5e");
expect(result.partyLevel).toBeUndefined();
});
});
@@ -1,14 +0,0 @@
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);
});
});
@@ -1,78 +0,0 @@
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);
});
});
@@ -1,237 +0,0 @@
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");
});
});
@@ -1,270 +0,0 @@
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" }],
});
});
});
@@ -301,80 +301,6 @@ describe("rehydrateCombatant", () => {
expect(result?.side).toBeUndefined(); 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", () => { it("drops invalid tempHp — keeps combatant", () => {
for (const tempHp of [-1, 1.5, "3"]) { for (const tempHp of [-1, 1.5, "3"]) {
const result = rehydrateCombatant({ const result = rehydrateCombatant({
@@ -1,159 +0,0 @@
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("appends new conditions to the end (insertion order)", () => { it("maintains definition order when adding conditions", () => {
const e = enc([makeCombatant("A", [{ id: "poisoned" }])]); const e = enc([makeCombatant("A", [{ id: "poisoned" }])]);
const { encounter } = success(e, "A", "blinded"); const { encounter } = success(e, "A", "blinded");
expect(encounter.combatants[0].conditions).toEqual([ expect(encounter.combatants[0].conditions).toEqual([
{ id: "poisoned" },
{ id: "blinded" }, { id: "blinded" },
{ id: "poisoned" },
]); ]);
}); });
@@ -109,16 +109,15 @@ describe("toggleCondition", () => {
expect(encounter.combatants[0].conditions).toBeUndefined(); expect(encounter.combatants[0].conditions).toBeUndefined();
}); });
it("preserves insertion order across all conditions", () => { it("preserves order across all conditions", () => {
const order = CONDITION_DEFINITIONS.map((d) => d.id); const order = CONDITION_DEFINITIONS.map((d) => d.id);
// Add in reverse order — result should be reverse order (insertion order) // Add in reverse order
const reversed = [...order].reverse();
let e = enc([makeCombatant("A")]); let e = enc([makeCombatant("A")]);
for (const cond of reversed) { for (const cond of [...order].reverse()) {
const result = success(e, "A", cond); const result = success(e, "A", cond);
e = result.encounter; e = result.encounter;
} }
expect(e.combatants[0].conditions).toEqual(reversed.map((id) => ({ id }))); expect(e.combatants[0].conditions).toEqual(order.map((id) => ({ id })));
}); });
}); });
-4
View File
@@ -14,7 +14,6 @@ export interface CombatantInit {
readonly ac?: number; readonly ac?: number;
readonly initiative?: number; readonly initiative?: number;
readonly creatureId?: CreatureId; readonly creatureId?: CreatureId;
readonly creatureAdjustment?: "weak" | "elite";
readonly color?: string; readonly color?: string;
readonly icon?: string; readonly icon?: string;
readonly playerCharacterId?: PlayerCharacterId; readonly playerCharacterId?: PlayerCharacterId;
@@ -68,9 +67,6 @@ function buildCombatant(
...(init?.ac !== undefined && { ac: init.ac }), ...(init?.ac !== undefined && { ac: init.ac }),
...(init?.initiative !== undefined && { initiative: init.initiative }), ...(init?.initiative !== undefined && { initiative: init.initiative }),
...(init?.creatureId !== undefined && { creatureId: init.creatureId }), ...(init?.creatureId !== undefined && { creatureId: init.creatureId }),
...(init?.creatureAdjustment !== undefined && {
creatureAdjustment: init.creatureAdjustment,
}),
...(init?.color !== undefined && { color: init.color }), ...(init?.color !== undefined && { color: init.color }),
...(init?.icon !== undefined && { icon: init.icon }), ...(init?.icon !== undefined && { icon: init.icon }),
...(init?.playerCharacterId !== undefined && { ...(init?.playerCharacterId !== undefined && {
+2 -2
View File
@@ -500,8 +500,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
description5e: "", description5e: "",
descriptionPf2e: descriptionPf2e:
"Location unknown. Must pick a square to target; DC 11 flat check. Attacker is off-guard against your attacks.", "Location unknown. Must pick a square to target; DC 11 flat check. Attacker is off-guard against your attacks.",
iconName: "EyeClosed", iconName: "Ghost",
color: "slate", color: "violet",
systems: ["pf2e"], systems: ["pf2e"],
}, },
{ {
-2
View File
@@ -23,7 +23,6 @@ export interface TraitBlock {
readonly name: string; readonly name: string;
readonly activity?: ActivityCost; readonly activity?: ActivityCost;
readonly trigger?: string; readonly trigger?: string;
readonly frequency?: string;
readonly segments: readonly TraitSegment[]; readonly segments: readonly TraitSegment[];
} }
@@ -186,7 +185,6 @@ export interface Pf2eCreature {
readonly level: number; readonly level: number;
readonly traits: readonly string[]; readonly traits: readonly string[];
readonly perception: number; readonly perception: number;
readonly perceptionDetails?: string;
readonly senses?: string; readonly senses?: string;
readonly languages?: string; readonly languages?: string;
readonly skills?: string; readonly skills?: string;
+9 -205
View File
@@ -1,7 +1,7 @@
import type { RulesEdition } from "./rules-edition.js"; import type { RulesEdition } from "./rules-edition.js";
/** Abstract difficulty severity: 0 = negligible, up to 4 (PF2e Extreme). Maps to filled bar count. */ /** Abstract difficulty severity: 0 = negligible, 3 = maximum. Maps to filled bar count. */
export type DifficultyTier = 0 | 1 | 2 | 3 | 4; export type DifficultyTier = 0 | 1 | 2 | 3;
export interface DifficultyThreshold { export interface DifficultyThreshold {
readonly label: string; readonly label: string;
@@ -18,8 +18,6 @@ export interface DifficultyResult {
readonly adjustedXp: number | undefined; readonly adjustedXp: number | undefined;
/** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */ /** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */
readonly partySizeAdjusted: boolean | undefined; readonly partySizeAdjusted: boolean | undefined;
/** PF2e only: the derived party level used for XP calculation. */
readonly partyLevel: number | undefined;
} }
/** Maps challenge rating strings to XP values (standard 5e). */ /** Maps challenge rating strings to XP values (standard 5e). */
@@ -162,143 +160,6 @@ 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. */ /** All standard 5e challenge rating strings, in ascending order. */
export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP); export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP);
@@ -310,15 +171,10 @@ export function crToXp(cr: string): number {
export interface CombatantDescriptor { export interface CombatantDescriptor {
readonly level?: number; readonly level?: number;
readonly cr?: string; readonly cr?: string;
readonly creatureLevel?: number;
readonly side: "party" | "enemy"; readonly side: "party" | "enemy";
} }
/** 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, xp: number,
tierThresholds: readonly number[], tierThresholds: readonly number[],
): DifficultyTier { ): DifficultyTier {
@@ -328,23 +184,6 @@ function tierFromThresholds(
return 0; 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[]) { function accumulateBudget5_5e(levels: readonly number[]) {
const budget = { low: 0, moderate: 0, high: 0 }; const budget = { low: 0, moderate: 0, high: 0 };
for (const level of levels) { for (const level of levels) {
@@ -408,41 +247,6 @@ export function calculateEncounterDifficulty(
combatants: readonly CombatantDescriptor[], combatants: readonly CombatantDescriptor[],
edition: RulesEdition, edition: RulesEdition,
): DifficultyResult { ): DifficultyResult {
if (edition === "pf2e") {
const partyLevels: number[] = [];
for (const c of combatants) {
if (c.level !== undefined && c.side === "party") {
partyLevels.push(c.level);
}
}
const partyLevel = derivePartyLevel(partyLevels);
const { totalCreatureXp } = scanCombatantsPf2e(combatants, partyLevel);
const budget = calculatePf2eBudget(partyLevels.length);
const thresholds: DifficultyThreshold[] = [
{ label: "Trivial", value: budget.trivial },
{ label: "Low", value: budget.low },
{ label: "Moderate", value: budget.moderate },
{ label: "Severe", value: budget.severe },
{ label: "Extreme", value: budget.extreme },
];
return {
tier: tierFromBudgets(totalCreatureXp, [
budget.trivial,
budget.low,
budget.moderate,
budget.severe,
]),
totalMonsterXp: totalCreatureXp,
thresholds,
encounterMultiplier: undefined,
adjustedXp: undefined,
partySizeAdjusted: undefined,
partyLevel,
};
}
const { totalMonsterXp, monsterCount, partyLevels } = const { totalMonsterXp, monsterCount, partyLevels } =
scanCombatants(combatants); scanCombatants(combatants);
@@ -454,15 +258,16 @@ export function calculateEncounterDifficulty(
{ label: "High", value: budget.high }, { label: "High", value: budget.high },
]; ];
return { return {
// 5.5e has no tier below Low, so tier 0 (empty bars) only at 0 XP. tier: determineTier(totalMonsterXp, [
// The High budget is display-only: anything over Moderate is High. budget.low,
tier: tierFromBudgets(totalMonsterXp, [0, budget.low, budget.moderate]), budget.moderate,
budget.high,
]),
totalMonsterXp, totalMonsterXp,
thresholds, thresholds,
encounterMultiplier: undefined, encounterMultiplier: undefined,
adjustedXp: undefined, adjustedXp: undefined,
partySizeAdjusted: undefined, partySizeAdjusted: undefined,
partyLevel: undefined,
}; };
} }
@@ -479,7 +284,7 @@ export function calculateEncounterDifficulty(
]; ];
return { return {
tier: tierFromThresholds(adjustedXp, [ tier: determineTier(adjustedXp, [
budget.medium, budget.medium,
budget.hard, budget.hard,
budget.deadly, budget.deadly,
@@ -489,6 +294,5 @@ export function calculateEncounterDifficulty(
encounterMultiplier, encounterMultiplier,
adjustedXp, adjustedXp,
partySizeAdjusted, partySizeAdjusted,
partyLevel: undefined,
}; };
} }
-32
View File
@@ -1,6 +1,5 @@
import type { ConditionId } from "./conditions.js"; import type { ConditionId } from "./conditions.js";
import type { CreatureId } from "./creature-types.js"; import type { CreatureId } from "./creature-types.js";
import type { PersistentDamageType } from "./persistent-damage-types.js";
import type { PlayerCharacterId } from "./player-character-types.js"; import type { PlayerCharacterId } from "./player-character-types.js";
import type { CombatantId } from "./types.js"; import type { CombatantId } from "./types.js";
@@ -133,33 +132,6 @@ export interface ConcentrationEnded {
readonly combatantId: CombatantId; readonly combatantId: CombatantId;
} }
export interface PersistentDamageAdded {
readonly type: "PersistentDamageAdded";
readonly combatantId: CombatantId;
readonly damageType: PersistentDamageType;
readonly formula: string;
}
export interface PersistentDamageRemoved {
readonly type: "PersistentDamageRemoved";
readonly combatantId: CombatantId;
readonly damageType: PersistentDamageType;
}
export interface CreatureAdjustmentSet {
readonly type: "CreatureAdjustmentSet";
readonly combatantId: CombatantId;
readonly adjustment: "weak" | "elite" | undefined;
}
export interface HpVariantSet {
readonly type: "HpVariantSet";
readonly combatantId: CombatantId;
readonly variant: "min" | "max" | undefined;
readonly previousMaxHp: number | undefined;
readonly newMaxHp: number | undefined;
}
export interface EncounterCleared { export interface EncounterCleared {
readonly type: "EncounterCleared"; readonly type: "EncounterCleared";
readonly combatantCount: number; readonly combatantCount: number;
@@ -203,10 +175,6 @@ export type DomainEvent =
| ConditionRemoved | ConditionRemoved
| ConcentrationStarted | ConcentrationStarted
| ConcentrationEnded | ConcentrationEnded
| PersistentDamageAdded
| PersistentDamageRemoved
| CreatureAdjustmentSet
| HpVariantSet
| EncounterCleared | EncounterCleared
| PlayerCharacterCreated | PlayerCharacterCreated
| PlayerCharacterUpdated | PlayerCharacterUpdated
-58
View File
@@ -1,58 +0,0 @@
/** 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;
}
-36
View File
@@ -64,8 +64,6 @@ export {
type DifficultyResult, type DifficultyResult,
type DifficultyThreshold, type DifficultyThreshold,
type DifficultyTier, type DifficultyTier,
derivePartyLevel,
pf2eCreatureXp,
VALID_CR_VALUES, VALID_CR_VALUES,
} from "./encounter-difficulty.js"; } from "./encounter-difficulty.js";
export type { export type {
@@ -77,16 +75,12 @@ export type {
ConcentrationStarted, ConcentrationStarted,
ConditionAdded, ConditionAdded,
ConditionRemoved, ConditionRemoved,
CreatureAdjustmentSet,
CrSet, CrSet,
CurrentHpAdjusted, CurrentHpAdjusted,
DomainEvent, DomainEvent,
EncounterCleared, EncounterCleared,
HpVariantSet,
InitiativeSet, InitiativeSet,
MaxHpSet, MaxHpSet,
PersistentDamageAdded,
PersistentDamageRemoved,
PlayerCharacterCreated, PlayerCharacterCreated,
PlayerCharacterDeleted, PlayerCharacterDeleted,
PlayerCharacterUpdated, PlayerCharacterUpdated,
@@ -98,13 +92,6 @@ export type {
TurnRetreated, TurnRetreated,
} from "./events.js"; } from "./events.js";
export type { ExportBundle } from "./export-bundle.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 { deriveHpStatus, type HpStatus } from "./hp-status.js";
export { export {
calculateInitiative, calculateInitiative,
@@ -112,25 +99,6 @@ export {
formatInitiativeModifier, formatInitiativeModifier,
type InitiativeResult, type InitiativeResult,
} from "./initiative.js"; } from "./initiative.js";
export {
addPersistentDamage,
PERSISTENT_DAMAGE_DEFINITIONS,
PERSISTENT_DAMAGE_TYPES,
type PersistentDamageDefinition,
type PersistentDamageEntry,
type PersistentDamageSuccess,
type PersistentDamageType,
removePersistentDamage,
VALID_PERSISTENT_DAMAGE_TYPES,
} from "./persistent-damage.js";
export {
acDelta,
adjustedLevel,
applyPf2eAdjustment,
type CreatureAdjustment,
hpDelta,
modDelta,
} from "./pf2e-adjustments.js";
export { export {
type PlayerCharacter, type PlayerCharacter,
type PlayerCharacterId, type PlayerCharacterId,
@@ -161,10 +129,6 @@ export type { RulesEdition } from "./rules-edition.js";
export { type SetAcSuccess, setAc } from "./set-ac.js"; export { type SetAcSuccess, setAc } from "./set-ac.js";
export { type SetCrSuccess, setCr } from "./set-cr.js"; export { type SetCrSuccess, setCr } from "./set-cr.js";
export { type SetHpSuccess, setHp } from "./set-hp.js"; export { type SetHpSuccess, setHp } from "./set-hp.js";
export {
type SetHpVariantSuccess,
setHpVariant,
} from "./set-hp-variant.js";
export { export {
type SetInitiativeSuccess, type SetInitiativeSuccess,
setInitiative, setInitiative,
@@ -1,78 +0,0 @@
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",
},
];
-121
View File
@@ -1,121 +0,0 @@
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 }],
};
}
-110
View File
@@ -1,110 +0,0 @@
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)),
};
}
@@ -2,10 +2,6 @@ import type { ConditionEntry, ConditionId } from "./conditions.js";
import { VALID_CONDITION_IDS } from "./conditions.js"; import { VALID_CONDITION_IDS } from "./conditions.js";
import { creatureId } from "./creature-types.js"; import { creatureId } from "./creature-types.js";
import { VALID_CR_VALUES } from "./encounter-difficulty.js"; import { VALID_CR_VALUES } from "./encounter-difficulty.js";
import type { 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 { import {
playerCharacterId, playerCharacterId,
VALID_PLAYER_COLORS, VALID_PLAYER_COLORS,
@@ -46,32 +42,6 @@ function validateConditions(value: unknown): ConditionEntry[] | undefined {
return entries.length > 0 ? entries : undefined; return entries.length > 0 ? entries : undefined;
} }
function validatePersistentDamage(
value: unknown,
): PersistentDamageEntry[] | undefined {
if (!Array.isArray(value)) return undefined;
const entries: PersistentDamageEntry[] = [];
for (const item of value) {
if (
typeof item === "object" &&
item !== null &&
typeof (item as Record<string, unknown>).type === "string" &&
VALID_PERSISTENT_DAMAGE_TYPES.has(
(item as Record<string, unknown>).type as string,
) &&
typeof (item as Record<string, unknown>).formula === "string" &&
((item as Record<string, unknown>).formula as string).length > 0
) {
entries.push({
type: (item as Record<string, unknown>)
.type as PersistentDamageEntry["type"],
formula: (item as Record<string, unknown>).formula as string,
});
}
}
return entries.length > 0 ? entries : undefined;
}
function validateHp( function validateHp(
rawMaxHp: unknown, rawMaxHp: unknown,
rawCurrentHp: unknown, rawCurrentHp: unknown,
@@ -123,7 +93,6 @@ function validateCr(value: unknown): string | undefined {
: undefined; : undefined;
} }
const VALID_ADJUSTMENTS = new Set(["weak", "elite"]);
const VALID_SIDES = new Set(["party", "enemy"]); const VALID_SIDES = new Set(["party", "enemy"]);
function validateSide(value: unknown): "party" | "enemy" | undefined { function validateSide(value: unknown): "party" | "enemy" | undefined {
@@ -137,18 +106,10 @@ function parseOptionalFields(entry: Record<string, unknown>) {
initiative: validateInteger(entry.initiative), initiative: validateInteger(entry.initiative),
ac: validateAc(entry.ac), ac: validateAc(entry.ac),
conditions: validateConditions(entry.conditions), conditions: validateConditions(entry.conditions),
persistentDamage: validatePersistentDamage(entry.persistentDamage),
isConcentrating: entry.isConcentrating === true ? true : undefined, isConcentrating: entry.isConcentrating === true ? true : undefined,
creatureId: validateNonEmptyString(entry.creatureId) creatureId: validateNonEmptyString(entry.creatureId)
? creatureId(entry.creatureId as string) ? creatureId(entry.creatureId as string)
: undefined, : undefined,
creatureAdjustment: validateSetMember(
entry.creatureAdjustment,
VALID_ADJUSTMENTS,
) as "weak" | "elite" | undefined,
hpVariant: validateSetMember(entry.hpVariant, VALID_HP_VARIANTS) as
| HpVariant
| undefined,
cr: validateCr(entry.cr), cr: validateCr(entry.cr),
side: validateSide(entry.side), side: validateSide(entry.side),
color: validateSetMember(entry.color, VALID_PLAYER_COLORS), color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
-73
View File
@@ -1,73 +0,0 @@
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,
},
],
};
}
+12 -2
View File
@@ -14,6 +14,12 @@ export interface ToggleConditionSuccess {
readonly events: DomainEvent[]; readonly events: DomainEvent[];
} }
function sortByDefinitionOrder(entries: ConditionEntry[]): ConditionEntry[] {
const order = CONDITION_DEFINITIONS.map((d) => d.id);
entries.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
return entries;
}
function validateConditionId(conditionId: ConditionId): DomainError | null { function validateConditionId(conditionId: ConditionId): DomainError | null {
if (!VALID_CONDITION_IDS.has(conditionId)) { if (!VALID_CONDITION_IDS.has(conditionId)) {
return { return {
@@ -61,7 +67,8 @@ export function toggleCondition(
newConditions = filtered.length > 0 ? filtered : undefined; newConditions = filtered.length > 0 ? filtered : undefined;
event = { type: "ConditionRemoved", combatantId, condition: conditionId }; event = { type: "ConditionRemoved", combatantId, condition: conditionId };
} else { } else {
newConditions = [...current, { id: conditionId }]; const added = sortByDefinitionOrder([...current, { id: conditionId }]);
newConditions = added;
event = { type: "ConditionAdded", combatantId, condition: conditionId }; event = { type: "ConditionAdded", combatantId, condition: conditionId };
} }
@@ -118,7 +125,10 @@ export function setConditionValue(
}; };
} }
const added = [...current, { id: conditionId, value: clampedValue }]; const added = sortByDefinitionOrder([
...current,
{ id: conditionId, value: clampedValue },
]);
return { return {
encounter: applyConditions(encounter, combatantId, added), encounter: applyConditions(encounter, combatantId, added),
events: [ events: [
-5
View File
@@ -7,8 +7,6 @@ export function combatantId(id: string): CombatantId {
import type { ConditionEntry } from "./conditions.js"; import type { ConditionEntry } from "./conditions.js";
import type { CreatureId } from "./creature-types.js"; import type { CreatureId } from "./creature-types.js";
import type { HpVariant } from "./hp-range.js";
import type { PersistentDamageEntry } from "./persistent-damage-types.js";
import type { PlayerCharacterId } from "./player-character-types.js"; import type { PlayerCharacterId } from "./player-character-types.js";
export interface Combatant { export interface Combatant {
@@ -20,11 +18,8 @@ export interface Combatant {
readonly tempHp?: number; readonly tempHp?: number;
readonly ac?: number; readonly ac?: number;
readonly conditions?: readonly ConditionEntry[]; readonly conditions?: readonly ConditionEntry[];
readonly persistentDamage?: readonly PersistentDamageEntry[];
readonly isConcentrating?: boolean; readonly isConcentrating?: boolean;
readonly creatureId?: CreatureId; readonly creatureId?: CreatureId;
readonly creatureAdjustment?: "weak" | "elite";
readonly hpVariant?: HpVariant;
readonly cr?: string; readonly cr?: string;
readonly side?: "party" | "enemy"; readonly side?: "party" | "enemy";
readonly color?: string; readonly color?: string;
+353 -643
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -4,8 +4,3 @@ packages:
onlyBuiltDependencies: onlyBuiltDependencies:
- lefthook - lefthook
# pnpm >= 10.32 reads overrides/auditConfig from here; the "pnpm" field in
# package.json is no longer honoured.
overrides:
picomatch: ">=4.0.4"
-83
View File
@@ -1,83 +0,0 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
const RUN_LINE_RE = /^\s*run:\s*(.+?)\s*$/;
/** @param {string} path */
const read = (path) => readFileSync(join(ROOT, path), "utf-8");
/**
* Gates must invoke `pnpm oxlint` with no extra arguments: pnpm forwards them
* after `--`, where oxlint reads them as file paths and lints nothing.
* @param {Record<string, string>} scripts
*/
function findGatesWithOwnFlags(scripts) {
const commands = Object.entries(scripts)
.filter(([name]) => name !== "oxlint")
.flatMap(([name, body]) =>
body.split("&&").map((c) => [`scripts.${name}`, c.trim()]),
);
for (const line of read("lefthook.yml").split("\n")) {
const command = RUN_LINE_RE.exec(line)?.[1];
if (command) commands.push(["lefthook.yml", command]);
}
return commands
.filter(([, c]) => c.includes("oxlint") && c !== "pnpm oxlint")
.map(([source, c]) => `${source} runs "${c}" instead of "pnpm oxlint"`);
}
/**
* Runs the configured command with one rule forced to warn. A working gate
* exits non-zero; exiting 0 means it lints nothing, or does not fail on
* warnings.
* @param {string} oxlintScript
*/
function failsOnWarnings(oxlintScript) {
const args = [...oxlintScript.split(" ").slice(1), "-W", "no-console"];
try {
execFileSync(join(ROOT, "node_modules/.bin/oxlint"), args, {
cwd: ROOT,
stdio: "ignore",
});
return false;
} catch {
return true;
}
}
/**
* Reports ways a quality gate could pass without checking anything, which is
* indistinguishable from a clean run.
* @returns {string[]}
*/
export function checkGates() {
/** @type {Record<string, string>} */
const scripts = JSON.parse(read("package.json")).scripts;
const violations = findGatesWithOwnFlags(scripts);
// Vitest defaults to failing when no test file matches. Setting this puts
// the test gate back to exiting 0 if the include globs ever stop matching.
if (read("vitest.config.ts").includes("passWithNoTests")) {
violations.push("vitest.config.ts sets passWithNoTests");
}
// jscpd's pattern is a single glob string. An array matches no files, and
// the run then reports success having read nothing.
if (Array.isArray(JSON.parse(read(".jscpd.json")).pattern)) {
violations.push(".jscpd.json sets pattern to an array, not a glob string");
}
// The canary rule below is not type-aware, so the probe alone cannot
// detect type-aware rules being switched off.
if (!scripts.oxlint.includes("--type-aware")) {
violations.push("scripts.oxlint is missing --type-aware");
}
if (!failsOnWarnings(scripts.oxlint)) {
violations.push(
`scripts.oxlint exits 0 despite no-console violations: "${scripts.oxlint}"`,
);
}
return violations;
}
+1 -1
View File
@@ -63,7 +63,7 @@ function checkFile(file, forbidden) {
const lines = content.split("\n"); const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const match = IMPORT_RE.exec(lines[i]); const match = lines[i].match(IMPORT_RE);
if (!match) continue; if (!match) continue;
const importPath = match[1] || match[2]; const importPath = match[1] || match[2];

Some files were not shown because too many files have changed in this diff Show More