Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b1c1deda2 | ||
|
|
09a801487d | ||
|
|
a44f82127e | ||
|
|
c3707cf0b6 | ||
|
|
1eaeecad32 | ||
|
|
e2e8297c95 | ||
|
|
e161645228 | ||
|
|
9b0cb38897 | ||
|
|
5cb5721a6f | ||
|
|
48795071f7 | ||
|
|
f721d7e5da | ||
|
|
e7930a1431 | ||
|
|
553e09f280 | ||
|
|
1c107a500b | ||
|
|
0c235112ee | ||
|
|
57278e0c82 | ||
|
|
f9cfaa2570 | ||
|
|
3e62e54274 | ||
|
|
12a089dfd7 | ||
|
|
65e4db153b | ||
|
|
8dbff66ce1 | ||
|
|
e62c49434c | ||
|
|
8f6eebc43b | ||
|
|
817cfddabc | ||
|
|
94e1806112 |
@@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: commit
|
name: commit
|
||||||
description: Create a git commit with pre-commit hooks (bypasses sandbox restrictions).
|
description: Create a git commit with pre-commit hooks (bypasses sandbox restrictions).
|
||||||
disable-model-invocation: true
|
|
||||||
allowed-tools: Bash(git *), Bash(pnpm *)
|
allowed-tools: Bash(git *), Bash(pnpm *)
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
54
.claude/skills/ship/SKILL.md
Normal file
54
.claude/skills/ship/SKILL.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: ship
|
||||||
|
description: Commit, tag with the next version, and push to remote.
|
||||||
|
disable-model-invocation: true
|
||||||
|
allowed-tools: Bash(git *), Bash(pnpm *), Skill
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
Commit current changes, create the next version tag, and push everything to remote.
|
||||||
|
|
||||||
|
### Step 1 — Commit
|
||||||
|
|
||||||
|
Use the `/commit` skill to stage and commit changes. Pass along any user arguments as the commit message.
|
||||||
|
|
||||||
|
```
|
||||||
|
/commit $ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Tag
|
||||||
|
|
||||||
|
Get the latest tag and increment the patch number (e.g., `0.9.27` → `0.9.28`). Create the tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag --sort=-v:refname | head -1
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag <next-version>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3 — Push
|
||||||
|
|
||||||
|
Push the commit and tag to remote:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4 — Verify
|
||||||
|
|
||||||
|
Confirm the tag exists on the pushed commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline -1 --decorate
|
||||||
|
```
|
||||||
|
|
||||||
|
## User arguments
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
If the user provided arguments, treat them as the commit message or guidance for what to commit.
|
||||||
@@ -69,6 +69,7 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
|||||||
- **oxlint** for type-aware linting that Biome can't do. Configured in `.oxlintrc.json`.
|
- **oxlint** for type-aware linting that Biome can't do. Configured in `.oxlintrc.json`.
|
||||||
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports.
|
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports.
|
||||||
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
||||||
|
- **Reuse UI primitives** — before creating custom interactive elements (buttons, inputs, selects, dialogs), check `apps/web/src/components/ui/` for existing components with established variants and hover styles.
|
||||||
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
||||||
- **Tests** live in `__tests__/` directories adjacent to source. See the Testing section below for conventions on mocking, assertions, and per-layer approach.
|
- **Tests** live in `__tests__/` directories adjacent to source. See the Testing section below for conventions on mocking, assertions, and per-layer approach.
|
||||||
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
|
- **Quality gates** are enforced at pre-commit via Lefthook (parallel jobs). No gate may exist only as a CI step or manual process.
|
||||||
|
|||||||
@@ -29,6 +29,6 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"jsdom": "^29.0.1",
|
"jsdom": "^29.0.1",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"vite": "^8.0.1"
|
"vite": "^8.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
type Creature,
|
type AnyCreature,
|
||||||
type CreatureId,
|
type CreatureId,
|
||||||
EMPTY_UNDO_REDO_STATE,
|
EMPTY_UNDO_REDO_STATE,
|
||||||
type Encounter,
|
type Encounter,
|
||||||
@@ -12,10 +12,10 @@ export function createTestAdapters(options?: {
|
|||||||
encounter?: Encounter | null;
|
encounter?: Encounter | null;
|
||||||
undoRedoState?: UndoRedoState;
|
undoRedoState?: UndoRedoState;
|
||||||
playerCharacters?: PlayerCharacter[];
|
playerCharacters?: PlayerCharacter[];
|
||||||
creatures?: Map<CreatureId, Creature>;
|
creatures?: Map<CreatureId, AnyCreature>;
|
||||||
sources?: Map<
|
sources?: Map<
|
||||||
string,
|
string,
|
||||||
{ displayName: string; creatures: Creature[]; cachedAt: number }
|
{ displayName: string; creatures: AnyCreature[]; cachedAt: number }
|
||||||
>;
|
>;
|
||||||
}): Adapters {
|
}): Adapters {
|
||||||
let storedEncounter = options?.encounter ?? null;
|
let storedEncounter = options?.encounter ?? null;
|
||||||
@@ -25,7 +25,7 @@ export function createTestAdapters(options?: {
|
|||||||
options?.sources ??
|
options?.sources ??
|
||||||
new Map<
|
new Map<
|
||||||
string,
|
string,
|
||||||
{ displayName: string; creatures: Creature[]; cachedAt: number }
|
{ displayName: string; creatures: AnyCreature[]; cachedAt: number }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
// Pre-populate sourceStore from creatures map if provided
|
// Pre-populate sourceStore from creatures map if provided
|
||||||
@@ -33,7 +33,7 @@ export function createTestAdapters(options?: {
|
|||||||
// No-op: creatures are accessed directly from the map
|
// No-op: creatures are accessed directly from the map
|
||||||
}
|
}
|
||||||
|
|
||||||
const creatureMap = options?.creatures ?? new Map<CreatureId, Creature>();
|
const creatureMap = options?.creatures ?? new Map<CreatureId, AnyCreature>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encounterPersistence: {
|
encounterPersistence: {
|
||||||
@@ -55,8 +55,9 @@ export function createTestAdapters(options?: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
bestiaryCache: {
|
bestiaryCache: {
|
||||||
cacheSource(sourceCode, displayName, creatures) {
|
cacheSource(system, sourceCode, displayName, creatures) {
|
||||||
sourceStore.set(sourceCode, {
|
const key = `${system}:${sourceCode}`;
|
||||||
|
sourceStore.set(key, {
|
||||||
displayName,
|
displayName,
|
||||||
creatures,
|
creatures,
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
@@ -66,21 +67,25 @@ export function createTestAdapters(options?: {
|
|||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
isSourceCached(sourceCode) {
|
isSourceCached(system, sourceCode) {
|
||||||
return Promise.resolve(sourceStore.has(sourceCode));
|
return Promise.resolve(sourceStore.has(`${system}:${sourceCode}`));
|
||||||
},
|
},
|
||||||
getCachedSources() {
|
getCachedSources(system) {
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
[...sourceStore.entries()].map(([sourceCode, info]) => ({
|
[...sourceStore.entries()]
|
||||||
sourceCode,
|
.filter(([key]) => !system || key.startsWith(`${system}:`))
|
||||||
|
.map(([key, info]) => ({
|
||||||
|
sourceCode: key.includes(":")
|
||||||
|
? key.slice(key.indexOf(":") + 1)
|
||||||
|
: key,
|
||||||
displayName: info.displayName,
|
displayName: info.displayName,
|
||||||
creatureCount: info.creatures.length,
|
creatureCount: info.creatures.length,
|
||||||
cachedAt: info.cachedAt,
|
cachedAt: info.cachedAt,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
clearSource(sourceCode) {
|
clearSource(system, sourceCode) {
|
||||||
sourceStore.delete(sourceCode);
|
sourceStore.delete(`${system}:${sourceCode}`);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
clearAll() {
|
clearAll() {
|
||||||
@@ -104,5 +109,14 @@ export function createTestAdapters(options?: {
|
|||||||
},
|
},
|
||||||
getSourceDisplayName: (sourceCode) => sourceCode,
|
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||||
},
|
},
|
||||||
|
pf2eBestiaryIndex: {
|
||||||
|
loadIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
getDefaultFetchUrl: (sourceCode) =>
|
||||||
|
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
|
||||||
|
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||||
|
getCreaturePathsForSource: () => [],
|
||||||
|
getCreatureNamesByPaths: () => new Map(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ async function addCombatant(
|
|||||||
opts?: { maxHp?: string },
|
opts?: { maxHp?: string },
|
||||||
) {
|
) {
|
||||||
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
|
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
|
||||||
// biome-ignore lint/style/noNonNullAssertion: getAllBy always returns at least one
|
const input = inputs.at(-1) ?? inputs[0];
|
||||||
const input = inputs.at(-1)!;
|
|
||||||
await user.type(input, name);
|
await user.type(input, name);
|
||||||
|
|
||||||
if (opts?.maxHp) {
|
if (opts?.maxHp) {
|
||||||
|
|||||||
@@ -198,21 +198,23 @@ describe("ConfirmButton", () => {
|
|||||||
|
|
||||||
it("Enter/Space keydown stops propagation to prevent parent handlers", () => {
|
it("Enter/Space keydown stops propagation to prevent parent handlers", () => {
|
||||||
const parentHandler = vi.fn();
|
const parentHandler = vi.fn();
|
||||||
render(
|
function Wrapper() {
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
|
return (
|
||||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: test wrapper
|
<button type="button" onKeyDown={parentHandler}>
|
||||||
<div onKeyDown={parentHandler}>
|
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<XIcon />}
|
icon={<XIcon />}
|
||||||
label="Remove combatant"
|
label="Remove combatant"
|
||||||
onConfirm={vi.fn()}
|
onConfirm={vi.fn()}
|
||||||
/>
|
/>
|
||||||
</div>,
|
</button>
|
||||||
);
|
);
|
||||||
const button = screen.getByRole("button");
|
}
|
||||||
|
render(<Wrapper />);
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const confirmButton = buttons.at(-1) ?? buttons[0];
|
||||||
|
|
||||||
fireEvent.keyDown(button, { key: "Enter" });
|
fireEvent.keyDown(confirmButton, { key: "Enter" });
|
||||||
fireEvent.keyDown(button, { key: " " });
|
fireEvent.keyDown(confirmButton, { key: " " });
|
||||||
|
|
||||||
expect(parentHandler).not.toHaveBeenCalled();
|
expect(parentHandler).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -234,6 +234,57 @@ describe("round-trip: export then import", () => {
|
|||||||
expect(imported.encounter.combatants[0].cr).toBe("2");
|
expect(imported.encounter.combatants[0].cr).toBe("2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trips a combatant with side field", () => {
|
||||||
|
const encounterWithSide: Encounter = {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Allied Guard",
|
||||||
|
cr: "2",
|
||||||
|
side: "party",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Goblin",
|
||||||
|
side: "enemy",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const emptyUndoRedo: UndoRedoState = {
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
};
|
||||||
|
const bundle = assembleExportBundle(encounterWithSide, 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].side).toBe("party");
|
||||||
|
expect(imported.encounter.combatants[1].side).toBe("enemy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a combatant without side field as undefined", () => {
|
||||||
|
const encounterNoSide: Encounter = {
|
||||||
|
combatants: [{ id: combatantId("c-1"), name: "Custom" }],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const emptyUndoRedo: UndoRedoState = {
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
};
|
||||||
|
const bundle = assembleExportBundle(encounterNoSide, 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].side).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("round-trips an empty encounter", () => {
|
it("round-trips an empty encounter", () => {
|
||||||
const emptyEncounter: Encounter = {
|
const emptyEncounter: Encounter = {
|
||||||
combatants: [],
|
combatants: [],
|
||||||
|
|||||||
@@ -16,12 +16,18 @@ vi.mock("../contexts/bestiary-context.js", () => ({
|
|||||||
useBestiaryContext: vi.fn(),
|
useBestiaryContext: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../contexts/encounter-context.js", () => ({
|
||||||
|
useEncounterContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
|
||||||
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
|
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
|
||||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||||
|
const mockUseEncounterContext = vi.mocked(useEncounterContext);
|
||||||
|
|
||||||
const CLOSE_REGEX = /close/i;
|
const CLOSE_REGEX = /close/i;
|
||||||
const COLLAPSE_REGEX = /collapse/i;
|
const COLLAPSE_REGEX = /collapse/i;
|
||||||
@@ -82,6 +88,7 @@ function setupMocks(overrides: PanelOverrides = {}) {
|
|||||||
|
|
||||||
mockUseSidePanelContext.mockReturnValue({
|
mockUseSidePanelContext.mockReturnValue({
|
||||||
selectedCreatureId: panelRole === "browse" ? creatureId : null,
|
selectedCreatureId: panelRole === "browse" ? creatureId : null,
|
||||||
|
selectedCombatantId: null,
|
||||||
pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
|
pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
|
||||||
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
|
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
|
||||||
isWideDesktop: false,
|
isWideDesktop: false,
|
||||||
@@ -110,6 +117,11 @@ function setupMocks(overrides: PanelOverrides = {}) {
|
|||||||
refreshCache: vi.fn(),
|
refreshCache: vi.fn(),
|
||||||
} as ReturnType<typeof useBestiaryContext>);
|
} as ReturnType<typeof useBestiaryContext>);
|
||||||
|
|
||||||
|
mockUseEncounterContext.mockReturnValue({
|
||||||
|
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
|
||||||
|
setCreatureAdjustment: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof useEncounterContext>);
|
||||||
|
|
||||||
return { onToggleCollapse, onPin, onUnpin, onDismiss };
|
return { onToggleCollapse, onPin, onUnpin, onDismiss };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
|
import type { TraitBlock } from "@initiative/domain";
|
||||||
import { beforeAll, describe, expect, it } from "vitest";
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
normalizeBestiary,
|
normalizeBestiary,
|
||||||
setSourceDisplayNames,
|
setSourceDisplayNames,
|
||||||
} from "../bestiary-adapter.js";
|
} from "../bestiary-adapter.js";
|
||||||
|
|
||||||
|
/** Flatten segments to a single string for simple text assertions. */
|
||||||
|
function flatText(trait: TraitBlock | undefined): string {
|
||||||
|
if (!trait) return "";
|
||||||
|
return trait.segments
|
||||||
|
.map((s) =>
|
||||||
|
s.type === "text"
|
||||||
|
? s.value
|
||||||
|
: s.items
|
||||||
|
.map((i) => (i.label ? `${i.label}. ${i.text}` : i.text))
|
||||||
|
.join(" "),
|
||||||
|
)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
setSourceDisplayNames({ XMM: "MM 2024" });
|
setSourceDisplayNames({ XMM: "MM 2024" });
|
||||||
});
|
});
|
||||||
@@ -74,11 +89,11 @@ describe("normalizeBestiary", () => {
|
|||||||
expect(c.senses).toBe("Darkvision 60 ft.");
|
expect(c.senses).toBe("Darkvision 60 ft.");
|
||||||
expect(c.languages).toBe("Common, Goblin");
|
expect(c.languages).toBe("Common, Goblin");
|
||||||
expect(c.actions).toHaveLength(1);
|
expect(c.actions).toHaveLength(1);
|
||||||
expect(c.actions?.[0].text).toContain("Melee Attack Roll:");
|
expect(flatText(c.actions?.[0])).toContain("Melee Attack Roll:");
|
||||||
expect(c.actions?.[0].text).not.toContain("{@");
|
expect(flatText(c.actions?.[0])).not.toContain("{@");
|
||||||
expect(c.bonusActions).toHaveLength(1);
|
expect(c.bonusActions).toHaveLength(1);
|
||||||
expect(c.bonusActions?.[0].text).toContain("Disengage");
|
expect(flatText(c.bonusActions?.[0])).toContain("Disengage");
|
||||||
expect(c.bonusActions?.[0].text).not.toContain("{@");
|
expect(flatText(c.bonusActions?.[0])).not.toContain("{@");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes a creature with legendary actions", () => {
|
it("normalizes a creature with legendary actions", () => {
|
||||||
@@ -166,17 +181,20 @@ describe("normalizeBestiary", () => {
|
|||||||
expect(sc?.name).toBe("Spellcasting");
|
expect(sc?.name).toBe("Spellcasting");
|
||||||
expect(sc?.headerText).toContain("DC 15");
|
expect(sc?.headerText).toContain("DC 15");
|
||||||
expect(sc?.headerText).not.toContain("{@");
|
expect(sc?.headerText).not.toContain("{@");
|
||||||
expect(sc?.atWill).toEqual(["Detect Magic", "Mage Hand"]);
|
expect(sc?.atWill).toEqual([
|
||||||
|
{ name: "Detect Magic" },
|
||||||
|
{ name: "Mage Hand" },
|
||||||
|
]);
|
||||||
expect(sc?.daily).toHaveLength(2);
|
expect(sc?.daily).toHaveLength(2);
|
||||||
expect(sc?.daily).toContainEqual({
|
expect(sc?.daily).toContainEqual({
|
||||||
uses: 2,
|
uses: 2,
|
||||||
each: true,
|
each: true,
|
||||||
spells: ["Fireball"],
|
spells: [{ name: "Fireball" }],
|
||||||
});
|
});
|
||||||
expect(sc?.daily).toContainEqual({
|
expect(sc?.daily).toContainEqual({
|
||||||
uses: 1,
|
uses: 1,
|
||||||
each: false,
|
each: false,
|
||||||
spells: ["Dimension Door"],
|
spells: [{ name: "Dimension Door" }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,9 +351,9 @@ describe("normalizeBestiary", () => {
|
|||||||
|
|
||||||
const creatures = normalizeBestiary(raw);
|
const creatures = normalizeBestiary(raw);
|
||||||
const bite = creatures[0].actions?.[0];
|
const bite = creatures[0].actions?.[0];
|
||||||
expect(bite?.text).toContain("Melee Weapon Attack:");
|
expect(flatText(bite)).toContain("Melee Weapon Attack:");
|
||||||
expect(bite?.text).not.toContain("mw");
|
expect(flatText(bite)).not.toContain("mw");
|
||||||
expect(bite?.text).not.toContain("{@");
|
expect(flatText(bite)).not.toContain("{@");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles fly speed with hover condition", () => {
|
it("handles fly speed with hover condition", () => {
|
||||||
@@ -368,4 +386,131 @@ describe("normalizeBestiary", () => {
|
|||||||
const creatures = normalizeBestiary(raw);
|
const creatures = normalizeBestiary(raw);
|
||||||
expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)");
|
expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders list items with singular entry field (e.g. Confusing Burble d4 effects)", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Jabberwock",
|
||||||
|
source: "WBtW",
|
||||||
|
size: ["H"],
|
||||||
|
type: "dragon",
|
||||||
|
ac: [18],
|
||||||
|
hp: { average: 115, formula: "10d12 + 50" },
|
||||||
|
speed: { walk: 30 },
|
||||||
|
str: 22,
|
||||||
|
dex: 15,
|
||||||
|
con: 20,
|
||||||
|
int: 8,
|
||||||
|
wis: 14,
|
||||||
|
cha: 16,
|
||||||
|
passive: 12,
|
||||||
|
cr: "13",
|
||||||
|
trait: [
|
||||||
|
{
|
||||||
|
name: "Confusing Burble",
|
||||||
|
entries: [
|
||||||
|
"The jabberwock burbles unless {@condition incapacitated}. Roll a {@dice d4}:",
|
||||||
|
{
|
||||||
|
type: "list",
|
||||||
|
style: "list-hang-notitle",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
name: "1-2",
|
||||||
|
entry: "The creature does nothing.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
name: "3",
|
||||||
|
entry:
|
||||||
|
"The creature uses all its movement to move in a random direction.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "item",
|
||||||
|
name: "4",
|
||||||
|
entry:
|
||||||
|
"The creature makes one melee attack against a random creature.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const trait = creatures[0].traits?.[0];
|
||||||
|
expect(trait).toBeDefined();
|
||||||
|
expect(trait?.name).toBe("Confusing Burble");
|
||||||
|
expect(trait?.segments).toHaveLength(2);
|
||||||
|
expect(trait?.segments[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: expect.stringContaining("d4"),
|
||||||
|
});
|
||||||
|
expect(trait?.segments[1]).toEqual({
|
||||||
|
type: "list",
|
||||||
|
items: [
|
||||||
|
{ label: "1-2", text: "The creature does nothing." },
|
||||||
|
{
|
||||||
|
label: "3",
|
||||||
|
text: expect.stringContaining("random direction"),
|
||||||
|
},
|
||||||
|
{ label: "4", text: expect.stringContaining("melee attack") },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders table entries as structured list segments", () => {
|
||||||
|
const raw = {
|
||||||
|
monster: [
|
||||||
|
{
|
||||||
|
name: "Test Creature",
|
||||||
|
source: "XMM",
|
||||||
|
size: ["M"],
|
||||||
|
type: "humanoid",
|
||||||
|
ac: [12],
|
||||||
|
hp: { average: 40, formula: "9d8" },
|
||||||
|
speed: { walk: 30 },
|
||||||
|
str: 10,
|
||||||
|
dex: 10,
|
||||||
|
con: 10,
|
||||||
|
int: 10,
|
||||||
|
wis: 10,
|
||||||
|
cha: 10,
|
||||||
|
passive: 10,
|
||||||
|
cr: "1",
|
||||||
|
trait: [
|
||||||
|
{
|
||||||
|
name: "Random Effect",
|
||||||
|
entries: [
|
||||||
|
"Roll on the table:",
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
colLabels: ["d4", "Effect"],
|
||||||
|
rows: [
|
||||||
|
["1", "Nothing happens."],
|
||||||
|
["2", "Something happens."],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const creatures = normalizeBestiary(raw);
|
||||||
|
const trait = creatures[0].traits?.[0];
|
||||||
|
expect(trait).toBeDefined();
|
||||||
|
expect(trait?.segments[1]).toEqual({
|
||||||
|
type: "list",
|
||||||
|
items: [
|
||||||
|
{ label: "1", text: "Nothing happens." },
|
||||||
|
{ label: "2", text: "Something happens." },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,17 +46,17 @@ describe("bestiary-cache fallback (IndexedDB unavailable)", () => {
|
|||||||
|
|
||||||
it("cacheSource falls back to in-memory store", async () => {
|
it("cacheSource falls back to in-memory store", async () => {
|
||||||
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
||||||
await cacheSource("MM", "Monster Manual", creatures);
|
await cacheSource("dnd", "MM", "Monster Manual", creatures);
|
||||||
|
|
||||||
expect(await isSourceCached("MM")).toBe(true);
|
expect(await isSourceCached("dnd", "MM")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("isSourceCached returns false for uncached source", async () => {
|
it("isSourceCached returns false for uncached source", async () => {
|
||||||
expect(await isSourceCached("XGE")).toBe(false);
|
expect(await isSourceCached("dnd", "XGE")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getCachedSources returns sources from in-memory store", async () => {
|
it("getCachedSources returns sources from in-memory store", async () => {
|
||||||
await cacheSource("MM", "Monster Manual", [
|
await cacheSource("dnd", "MM", "Monster Manual", [
|
||||||
makeCreature("mm:goblin", "Goblin"),
|
makeCreature("mm:goblin", "Goblin"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ describe("bestiary-cache fallback (IndexedDB unavailable)", () => {
|
|||||||
|
|
||||||
it("loadAllCachedCreatures assembles creatures from in-memory store", async () => {
|
it("loadAllCachedCreatures assembles creatures from in-memory store", async () => {
|
||||||
const goblin = makeCreature("mm:goblin", "Goblin");
|
const goblin = makeCreature("mm:goblin", "Goblin");
|
||||||
await cacheSource("MM", "Monster Manual", [goblin]);
|
await cacheSource("dnd", "MM", "Monster Manual", [goblin]);
|
||||||
|
|
||||||
const map = await loadAllCachedCreatures();
|
const map = await loadAllCachedCreatures();
|
||||||
expect(map.size).toBe(1);
|
expect(map.size).toBe(1);
|
||||||
@@ -76,17 +76,17 @@ describe("bestiary-cache fallback (IndexedDB unavailable)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("clearSource removes a single source from in-memory store", async () => {
|
it("clearSource removes a single source from in-memory store", async () => {
|
||||||
await cacheSource("MM", "Monster Manual", []);
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
await cacheSource("VGM", "Volo's Guide", []);
|
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||||
|
|
||||||
await clearSource("MM");
|
await clearSource("dnd", "MM");
|
||||||
|
|
||||||
expect(await isSourceCached("MM")).toBe(false);
|
expect(await isSourceCached("dnd", "MM")).toBe(false);
|
||||||
expect(await isSourceCached("VGM")).toBe(true);
|
expect(await isSourceCached("dnd", "VGM")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clearAll removes all data from in-memory store", async () => {
|
it("clearAll removes all data from in-memory store", async () => {
|
||||||
await cacheSource("MM", "Monster Manual", []);
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
await clearAll();
|
await clearAll();
|
||||||
|
|
||||||
const sources = await getCachedSources();
|
const sources = await getCachedSources();
|
||||||
|
|||||||
@@ -69,17 +69,17 @@ describe("bestiary-cache", () => {
|
|||||||
describe("cacheSource", () => {
|
describe("cacheSource", () => {
|
||||||
it("stores creatures and metadata", async () => {
|
it("stores creatures and metadata", async () => {
|
||||||
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
||||||
await cacheSource("MM", "Monster Manual", creatures);
|
await cacheSource("dnd", "MM", "Monster Manual", creatures);
|
||||||
|
|
||||||
expect(fakeStore.has("MM")).toBe(true);
|
expect(fakeStore.has("dnd:MM")).toBe(true);
|
||||||
const record = fakeStore.get("MM") as {
|
const record = fakeStore.get("dnd:MM") as {
|
||||||
sourceCode: string;
|
sourceCode: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
creatures: Creature[];
|
creatures: Creature[];
|
||||||
creatureCount: number;
|
creatureCount: number;
|
||||||
cachedAt: number;
|
cachedAt: number;
|
||||||
};
|
};
|
||||||
expect(record.sourceCode).toBe("MM");
|
expect(record.sourceCode).toBe("dnd:MM");
|
||||||
expect(record.displayName).toBe("Monster Manual");
|
expect(record.displayName).toBe("Monster Manual");
|
||||||
expect(record.creatures).toHaveLength(1);
|
expect(record.creatures).toHaveLength(1);
|
||||||
expect(record.creatureCount).toBe(1);
|
expect(record.creatureCount).toBe(1);
|
||||||
@@ -89,12 +89,12 @@ describe("bestiary-cache", () => {
|
|||||||
|
|
||||||
describe("isSourceCached", () => {
|
describe("isSourceCached", () => {
|
||||||
it("returns false for uncached source", async () => {
|
it("returns false for uncached source", async () => {
|
||||||
expect(await isSourceCached("XGE")).toBe(false);
|
expect(await isSourceCached("dnd", "XGE")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns true after caching", async () => {
|
it("returns true after caching", async () => {
|
||||||
await cacheSource("MM", "Monster Manual", []);
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
expect(await isSourceCached("MM")).toBe(true);
|
expect(await isSourceCached("dnd", "MM")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,11 +105,11 @@ describe("bestiary-cache", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns source info with creature counts", async () => {
|
it("returns source info with creature counts", async () => {
|
||||||
await cacheSource("MM", "Monster Manual", [
|
await cacheSource("dnd", "MM", "Monster Manual", [
|
||||||
makeCreature("mm:goblin", "Goblin"),
|
makeCreature("mm:goblin", "Goblin"),
|
||||||
makeCreature("mm:orc", "Orc"),
|
makeCreature("mm:orc", "Orc"),
|
||||||
]);
|
]);
|
||||||
await cacheSource("VGM", "Volo's Guide", [
|
await cacheSource("dnd", "VGM", "Volo's Guide", [
|
||||||
makeCreature("vgm:flind", "Flind"),
|
makeCreature("vgm:flind", "Flind"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -137,8 +137,8 @@ describe("bestiary-cache", () => {
|
|||||||
const orc = makeCreature("mm:orc", "Orc");
|
const orc = makeCreature("mm:orc", "Orc");
|
||||||
const flind = makeCreature("vgm:flind", "Flind");
|
const flind = makeCreature("vgm:flind", "Flind");
|
||||||
|
|
||||||
await cacheSource("MM", "Monster Manual", [goblin, orc]);
|
await cacheSource("dnd", "MM", "Monster Manual", [goblin, orc]);
|
||||||
await cacheSource("VGM", "Volo's Guide", [flind]);
|
await cacheSource("dnd", "VGM", "Volo's Guide", [flind]);
|
||||||
|
|
||||||
const map = await loadAllCachedCreatures();
|
const map = await loadAllCachedCreatures();
|
||||||
expect(map.size).toBe(3);
|
expect(map.size).toBe(3);
|
||||||
@@ -150,20 +150,20 @@ describe("bestiary-cache", () => {
|
|||||||
|
|
||||||
describe("clearSource", () => {
|
describe("clearSource", () => {
|
||||||
it("removes a single source", async () => {
|
it("removes a single source", async () => {
|
||||||
await cacheSource("MM", "Monster Manual", []);
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
await cacheSource("VGM", "Volo's Guide", []);
|
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||||
|
|
||||||
await clearSource("MM");
|
await clearSource("dnd", "MM");
|
||||||
|
|
||||||
expect(await isSourceCached("MM")).toBe(false);
|
expect(await isSourceCached("dnd", "MM")).toBe(false);
|
||||||
expect(await isSourceCached("VGM")).toBe(true);
|
expect(await isSourceCached("dnd", "VGM")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("clearAll", () => {
|
describe("clearAll", () => {
|
||||||
it("removes all cached data", async () => {
|
it("removes all cached data", async () => {
|
||||||
await cacheSource("MM", "Monster Manual", []);
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
await cacheSource("VGM", "Volo's Guide", []);
|
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||||
|
|
||||||
await clearAll();
|
await clearAll();
|
||||||
|
|
||||||
|
|||||||
1485
apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts
Normal file
1485
apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const PACK_DIR_PREFIX = /^pathfinder-monster-core\//;
|
||||||
|
const JSON_EXTENSION = /\.json$/;
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAllPf2eSourceCodes,
|
||||||
|
getCreaturePathsForSource,
|
||||||
|
getDefaultPf2eFetchUrl,
|
||||||
|
getPf2eSourceDisplayName,
|
||||||
|
loadPf2eBestiaryIndex,
|
||||||
|
} from "../pf2e-bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
describe("loadPf2eBestiaryIndex", () => {
|
||||||
|
it("returns an object with sources and creatures", () => {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
expect(index.sources).toBeDefined();
|
||||||
|
expect(index.creatures).toBeDefined();
|
||||||
|
expect(Array.isArray(index.creatures)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creatures have the expected PF2e shape", () => {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
expect(index.creatures.length).toBeGreaterThan(0);
|
||||||
|
const first = index.creatures[0];
|
||||||
|
expect(first).toHaveProperty("name");
|
||||||
|
expect(first).toHaveProperty("source");
|
||||||
|
expect(first).toHaveProperty("level");
|
||||||
|
expect(first).toHaveProperty("ac");
|
||||||
|
expect(first).toHaveProperty("hp");
|
||||||
|
expect(first).toHaveProperty("perception");
|
||||||
|
expect(first).toHaveProperty("size");
|
||||||
|
expect(first).toHaveProperty("type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains a substantial number of creatures", () => {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
expect(index.creatures.length).toBeGreaterThan(2500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creatures have size and type populated", () => {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
const withSize = index.creatures.filter((c) => c.size !== "");
|
||||||
|
const withType = index.creatures.filter((c) => c.type !== "");
|
||||||
|
expect(withSize.length).toBeGreaterThan(index.creatures.length * 0.9);
|
||||||
|
expect(withType.length).toBeGreaterThan(index.creatures.length * 0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same cached instance on subsequent calls", () => {
|
||||||
|
const a = loadPf2eBestiaryIndex();
|
||||||
|
const b = loadPf2eBestiaryIndex();
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllPf2eSourceCodes", () => {
|
||||||
|
it("returns all keys from the index sources", () => {
|
||||||
|
const codes = getAllPf2eSourceCodes();
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
expect(codes).toEqual(Object.keys(index.sources));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDefaultPf2eFetchUrl", () => {
|
||||||
|
it("returns Foundry VTT PF2e base URL", () => {
|
||||||
|
const url = getDefaultPf2eFetchUrl("pathfinder-monster-core");
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes custom base URL with trailing slash", () => {
|
||||||
|
const url = getDefaultPf2eFetchUrl(
|
||||||
|
"pathfinder-monster-core",
|
||||||
|
"https://example.com/pf2e",
|
||||||
|
);
|
||||||
|
expect(url).toBe("https://example.com/pf2e/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPf2eSourceDisplayName", () => {
|
||||||
|
it("returns display name for a known source", () => {
|
||||||
|
const name = getPf2eSourceDisplayName("pathfinder-monster-core");
|
||||||
|
expect(name).toBe("Monster Core");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to source code for unknown source", () => {
|
||||||
|
expect(getPf2eSourceDisplayName("UNKNOWN")).toBe("UNKNOWN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCreaturePathsForSource", () => {
|
||||||
|
it("returns file paths for a known source", () => {
|
||||||
|
const paths = getCreaturePathsForSource("pathfinder-monster-core");
|
||||||
|
expect(paths.length).toBeGreaterThan(100);
|
||||||
|
expect(paths[0]).toMatch(PACK_DIR_PREFIX);
|
||||||
|
expect(paths[0]).toMatch(JSON_EXTENSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for unknown source", () => {
|
||||||
|
expect(getCreaturePathsForSource("nonexistent")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
168
apps/web/src/adapters/__tests__/strip-foundry-tags.test.ts
Normal file
168
apps/web/src/adapters/__tests__/strip-foundry-tags.test.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { stripFoundryTags } from "../strip-foundry-tags.js";
|
||||||
|
|
||||||
|
describe("stripFoundryTags", () => {
|
||||||
|
describe("@Damage tags", () => {
|
||||||
|
it("formats damage with type bracket", () => {
|
||||||
|
expect(stripFoundryTags("@Damage[3d6+10[fire]]")).toBe("3d6+10 fire");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers display text when present", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@Damage[3d6+10[fire]]{3d6+10 fire damage}"),
|
||||||
|
).toBe("3d6+10 fire damage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple damage types", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@Damage[2d8+5[slashing]] plus @Damage[1d6[fire]]"),
|
||||||
|
).toBe("2d8+5 slashing plus 1d6 fire");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("@Check tags", () => {
|
||||||
|
it("formats basic saving throw", () => {
|
||||||
|
expect(stripFoundryTags("@Check[reflex|dc:33|basic]")).toBe(
|
||||||
|
"DC 33 basic Reflex",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats non-basic check", () => {
|
||||||
|
expect(stripFoundryTags("@Check[athletics|dc:25]")).toBe(
|
||||||
|
"DC 25 Athletics",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats check without DC", () => {
|
||||||
|
expect(stripFoundryTags("@Check[fortitude]")).toBe("Fortitude");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("@UUID tags", () => {
|
||||||
|
it("extracts display text", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags(
|
||||||
|
"@UUID[Compendium.pf2e.conditionitems.Item.Grabbed]{Grabbed}",
|
||||||
|
),
|
||||||
|
).toBe("Grabbed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts last segment when no display text", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@UUID[Compendium.pf2e.conditionitems.Item.Grabbed]"),
|
||||||
|
).toBe("Grabbed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("@Template tags", () => {
|
||||||
|
it("formats cone template", () => {
|
||||||
|
expect(stripFoundryTags("@Template[cone|distance:40]")).toBe(
|
||||||
|
"40-foot cone",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats emanation template", () => {
|
||||||
|
expect(stripFoundryTags("@Template[emanation|distance:10]")).toBe(
|
||||||
|
"10-foot emanation",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers display text", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@Template[cone|distance:40]{40-foot cone}"),
|
||||||
|
).toBe("40-foot cone");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unknown @Tag patterns", () => {
|
||||||
|
it("uses display text for unknown tags", () => {
|
||||||
|
expect(stripFoundryTags("@Localize[some.key]{Some Text}")).toBe(
|
||||||
|
"Some Text",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips unknown tags without display text", () => {
|
||||||
|
expect(stripFoundryTags("@Localize[some.key]")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML stripping", () => {
|
||||||
|
it("strips paragraph tags", () => {
|
||||||
|
expect(stripFoundryTags("<p>text</p>")).toBe("text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts br to newline", () => {
|
||||||
|
expect(stripFoundryTags("line1<br />line2")).toBe("line1\nline2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts hr to newline", () => {
|
||||||
|
expect(stripFoundryTags("before<hr />after")).toBe("before\nafter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves strong and em tags", () => {
|
||||||
|
expect(stripFoundryTags("<strong>bold</strong> <em>italic</em>")).toBe(
|
||||||
|
"<strong>bold</strong> <em>italic</em>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves list tags", () => {
|
||||||
|
expect(stripFoundryTags("<ul><li>first</li><li>second</li></ul>")).toBe(
|
||||||
|
"<ul><li>first</li><li>second</li></ul>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts p-to-p transitions to newlines", () => {
|
||||||
|
expect(stripFoundryTags("<p>first</p><p>second</p>")).toBe(
|
||||||
|
"first\nsecond",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips action-glyph spans", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags('<span class="action-glyph">1</span> Strike'),
|
||||||
|
).toBe("Strike");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML entities", () => {
|
||||||
|
it("decodes &", () => {
|
||||||
|
expect(stripFoundryTags("fire & ice")).toBe("fire & ice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes < and >", () => {
|
||||||
|
expect(stripFoundryTags("<tag>")).toBe("<tag>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes "", () => {
|
||||||
|
expect(stripFoundryTags(""hello"")).toBe('"hello"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("whitespace handling", () => {
|
||||||
|
it("collapses multiple spaces", () => {
|
||||||
|
expect(stripFoundryTags("a b c")).toBe("a b c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses multiple blank lines", () => {
|
||||||
|
expect(stripFoundryTags("a\n\n\nb")).toBe("a\nb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims leading and trailing whitespace", () => {
|
||||||
|
expect(stripFoundryTags(" hello ")).toBe("hello");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("combined/edge cases", () => {
|
||||||
|
it("handles enrichment tags inside HTML", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags(
|
||||||
|
"<p>Deal @Damage[2d6[fire]] damage, @Check[reflex|dc:20|basic] save.</p>",
|
||||||
|
),
|
||||||
|
).toBe("Deal 2d6 fire damage, DC 20 basic Reflex save.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty string", () => {
|
||||||
|
expect(stripFoundryTags("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -138,12 +138,20 @@ describe("stripTags", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles nested tags gracefully", () => {
|
it("handles sibling tags in the same string", () => {
|
||||||
expect(
|
expect(
|
||||||
stripTags("The spell {@spell Fireball|XPHB} deals {@damage 8d6}."),
|
stripTags("The spell {@spell Fireball|XPHB} deals {@damage 8d6}."),
|
||||||
).toBe("The spell Fireball deals 8d6.");
|
).toBe("The spell Fireball deals 8d6.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles nested tags (outer wrapping inner)", () => {
|
||||||
|
expect(
|
||||||
|
stripTags(
|
||||||
|
"{@b Arcane Innate Spells DC 24; 3rd {@spell fireball}, {@spell slow}}",
|
||||||
|
),
|
||||||
|
).toBe("Arcane Innate Spells DC 24; 3rd fireball, slow");
|
||||||
|
});
|
||||||
|
|
||||||
it("handles text with no tags", () => {
|
it("handles text with no tags", () => {
|
||||||
expect(stripTags("Just plain text.")).toBe("Just plain text.");
|
expect(stripTags("Just plain text.")).toBe("Just plain text.");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import type {
|
|||||||
DailySpells,
|
DailySpells,
|
||||||
LegendaryBlock,
|
LegendaryBlock,
|
||||||
SpellcastingBlock,
|
SpellcastingBlock,
|
||||||
|
SpellReference,
|
||||||
TraitBlock,
|
TraitBlock,
|
||||||
|
TraitListItem,
|
||||||
|
TraitSegment,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
||||||
import { stripTags } from "./strip-tags.js";
|
import { stripTags } from "./strip-tags.js";
|
||||||
@@ -63,11 +66,18 @@ interface RawEntryObject {
|
|||||||
type: string;
|
type: string;
|
||||||
items?: (
|
items?: (
|
||||||
| string
|
| string
|
||||||
| { type: string; name?: string; entries?: (string | RawEntryObject)[] }
|
| {
|
||||||
|
type: string;
|
||||||
|
name?: string;
|
||||||
|
entry?: string;
|
||||||
|
entries?: (string | RawEntryObject)[];
|
||||||
|
}
|
||||||
)[];
|
)[];
|
||||||
style?: string;
|
style?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
entries?: (string | RawEntryObject)[];
|
entries?: (string | RawEntryObject)[];
|
||||||
|
colLabels?: string[];
|
||||||
|
rows?: (string | RawEntryObject)[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawSpellcasting {
|
interface RawSpellcasting {
|
||||||
@@ -257,23 +267,34 @@ function formatConditionImmunities(
|
|||||||
.join(", ");
|
.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderListItem(item: string | RawEntryObject): string | undefined {
|
function toListItem(
|
||||||
|
item:
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
type: string;
|
||||||
|
name?: string;
|
||||||
|
entry?: string;
|
||||||
|
entries?: (string | RawEntryObject)[];
|
||||||
|
},
|
||||||
|
): TraitListItem | undefined {
|
||||||
if (typeof item === "string") {
|
if (typeof item === "string") {
|
||||||
return `• ${stripTags(item)}`;
|
return { text: stripTags(item) };
|
||||||
}
|
}
|
||||||
if (item.name && item.entries) {
|
if (item.name && item.entries) {
|
||||||
return `• ${stripTags(item.name)}: ${renderEntries(item.entries)}`;
|
return { label: stripTags(item.name), text: renderEntries(item.entries) };
|
||||||
|
}
|
||||||
|
if (item.name && item.entry) {
|
||||||
|
return { label: stripTags(item.name), text: stripTags(item.entry) };
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEntryObject(entry: RawEntryObject, parts: string[]): void {
|
function renderEntryObject(entry: RawEntryObject, parts: string[]): void {
|
||||||
if (entry.type === "list") {
|
if (entry.type === "list" || entry.type === "table") {
|
||||||
for (const item of entry.items ?? []) {
|
// Handled structurally in segmentizeEntries
|
||||||
const rendered = renderListItem(item);
|
return;
|
||||||
if (rendered) parts.push(rendered);
|
|
||||||
}
|
}
|
||||||
} else if (entry.type === "item" && entry.name && entry.entries) {
|
if (entry.type === "item" && entry.name && entry.entries) {
|
||||||
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
|
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
|
||||||
} else if (entry.entries) {
|
} else if (entry.entries) {
|
||||||
parts.push(renderEntries(entry.entries));
|
parts.push(renderEntries(entry.entries));
|
||||||
@@ -292,11 +313,67 @@ function renderEntries(entries: (string | RawEntryObject)[]): string {
|
|||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tableRowToListItem(row: (string | RawEntryObject)[]): TraitListItem {
|
||||||
|
return {
|
||||||
|
label: typeof row[0] === "string" ? stripTags(row[0]) : undefined,
|
||||||
|
text: row
|
||||||
|
.slice(1)
|
||||||
|
.map((cell) =>
|
||||||
|
typeof cell === "string" ? stripTags(cell) : renderEntries([cell]),
|
||||||
|
)
|
||||||
|
.join(" "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryToListSegment(entry: RawEntryObject): TraitSegment | undefined {
|
||||||
|
if (entry.type === "list") {
|
||||||
|
const items = (entry.items ?? [])
|
||||||
|
.map(toListItem)
|
||||||
|
.filter((i): i is TraitListItem => i !== undefined);
|
||||||
|
return items.length > 0 ? { type: "list", items } : undefined;
|
||||||
|
}
|
||||||
|
if (entry.type === "table" && entry.rows) {
|
||||||
|
const items = entry.rows.map(tableRowToListItem);
|
||||||
|
return items.length > 0 ? { type: "list", items } : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentizeEntries(
|
||||||
|
entries: (string | RawEntryObject)[],
|
||||||
|
): TraitSegment[] {
|
||||||
|
const segments: TraitSegment[] = [];
|
||||||
|
const textParts: string[] = [];
|
||||||
|
|
||||||
|
const flushText = () => {
|
||||||
|
if (textParts.length > 0) {
|
||||||
|
segments.push({ type: "text", value: textParts.join(" ") });
|
||||||
|
textParts.length = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
textParts.push(stripTags(entry));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const listSeg = entryToListSegment(entry);
|
||||||
|
if (listSeg) {
|
||||||
|
flushText();
|
||||||
|
segments.push(listSeg);
|
||||||
|
} else {
|
||||||
|
renderEntryObject(entry, textParts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushText();
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTraits(raw?: RawEntry[]): TraitBlock[] | undefined {
|
function normalizeTraits(raw?: RawEntry[]): TraitBlock[] | undefined {
|
||||||
if (!raw || raw.length === 0) return undefined;
|
if (!raw || raw.length === 0) return undefined;
|
||||||
return raw.map((t) => ({
|
return raw.map((t) => ({
|
||||||
name: stripTags(t.name),
|
name: stripTags(t.name),
|
||||||
text: renderEntries(t.entries),
|
segments: segmentizeEntries(t.entries),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +386,7 @@ function normalizeSpellcasting(
|
|||||||
const block: {
|
const block: {
|
||||||
name: string;
|
name: string;
|
||||||
headerText: string;
|
headerText: string;
|
||||||
atWill?: string[];
|
atWill?: SpellReference[];
|
||||||
daily?: DailySpells[];
|
daily?: DailySpells[];
|
||||||
restLong?: DailySpells[];
|
restLong?: DailySpells[];
|
||||||
} = {
|
} = {
|
||||||
@@ -320,7 +397,7 @@ function normalizeSpellcasting(
|
|||||||
const hidden = new Set(sc.hidden ?? []);
|
const hidden = new Set(sc.hidden ?? []);
|
||||||
|
|
||||||
if (sc.will && !hidden.has("will")) {
|
if (sc.will && !hidden.has("will")) {
|
||||||
block.atWill = sc.will.map((s) => stripTags(s));
|
block.atWill = sc.will.map((s) => ({ name: stripTags(s) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sc.daily) {
|
if (sc.daily) {
|
||||||
@@ -342,7 +419,7 @@ function parseDailyMap(map: Record<string, string[]>): DailySpells[] {
|
|||||||
return {
|
return {
|
||||||
uses,
|
uses,
|
||||||
each,
|
each,
|
||||||
spells: spells.map((s) => stripTags(s)),
|
spells: spells.map((s) => ({ name: stripTags(s) })),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -361,7 +438,7 @@ function normalizeLegendary(
|
|||||||
preamble,
|
preamble,
|
||||||
entries: raw.map((e) => ({
|
entries: raw.map((e) => ({
|
||||||
name: stripTags(e.name),
|
name: stripTags(e.name),
|
||||||
text: renderEntries(e.entries),
|
segments: segmentizeEntries(e.entries),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import type { Creature, CreatureId } from "@initiative/domain";
|
import type { AnyCreature, CreatureId } from "@initiative/domain";
|
||||||
import { type IDBPDatabase, openDB } from "idb";
|
import { type IDBPDatabase, openDB } from "idb";
|
||||||
|
|
||||||
const DB_NAME = "initiative-bestiary";
|
const DB_NAME = "initiative-bestiary";
|
||||||
const STORE_NAME = "sources";
|
const STORE_NAME = "sources";
|
||||||
const DB_VERSION = 2;
|
// v8 (2026-04-10): Attack effects, ability frequency, perception details added to PF2e creatures
|
||||||
|
const DB_VERSION = 8;
|
||||||
|
|
||||||
interface CachedSourceInfo {
|
interface CachedSourceInfo {
|
||||||
readonly sourceCode: string;
|
readonly sourceCode: string;
|
||||||
readonly displayName: string;
|
readonly displayName: string;
|
||||||
readonly creatureCount: number;
|
readonly creatureCount: number;
|
||||||
readonly cachedAt: number;
|
readonly cachedAt: number;
|
||||||
|
readonly system?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CachedSourceRecord {
|
interface CachedSourceRecord {
|
||||||
sourceCode: string;
|
sourceCode: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
creatures: Creature[];
|
creatures: AnyCreature[];
|
||||||
cachedAt: number;
|
cachedAt: number;
|
||||||
creatureCount: number;
|
creatureCount: number;
|
||||||
|
system?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let db: IDBPDatabase | null = null;
|
let db: IDBPDatabase | null = null;
|
||||||
@@ -26,6 +29,10 @@ let dbFailed = false;
|
|||||||
// In-memory fallback when IndexedDB is unavailable
|
// In-memory fallback when IndexedDB is unavailable
|
||||||
const memoryStore = new Map<string, CachedSourceRecord>();
|
const memoryStore = new Map<string, CachedSourceRecord>();
|
||||||
|
|
||||||
|
function scopedKey(system: string, sourceCode: string): string {
|
||||||
|
return `${system}:${sourceCode}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function getDb(): Promise<IDBPDatabase | null> {
|
async function getDb(): Promise<IDBPDatabase | null> {
|
||||||
if (db) return db;
|
if (db) return db;
|
||||||
if (dbFailed) return null;
|
if (dbFailed) return null;
|
||||||
@@ -38,8 +45,11 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
|||||||
keyPath: "sourceCode",
|
keyPath: "sourceCode",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
if (
|
||||||
// Clear cached creatures to pick up improved tag processing
|
oldVersion < DB_VERSION &&
|
||||||
|
database.objectStoreNames.contains(STORE_NAME)
|
||||||
|
) {
|
||||||
|
// Clear cached creatures so they get re-normalized with latest rendering
|
||||||
void transaction.objectStore(STORE_NAME).clear();
|
void transaction.objectStore(STORE_NAME).clear();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -55,60 +65,77 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cacheSource(
|
export async function cacheSource(
|
||||||
|
system: string,
|
||||||
sourceCode: string,
|
sourceCode: string,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
creatures: Creature[],
|
creatures: AnyCreature[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const key = scopedKey(system, sourceCode);
|
||||||
const record: CachedSourceRecord = {
|
const record: CachedSourceRecord = {
|
||||||
sourceCode,
|
sourceCode: key,
|
||||||
displayName,
|
displayName,
|
||||||
creatures,
|
creatures,
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
creatureCount: creatures.length,
|
creatureCount: creatures.length,
|
||||||
|
system,
|
||||||
};
|
};
|
||||||
|
|
||||||
const database = await getDb();
|
const database = await getDb();
|
||||||
if (database) {
|
if (database) {
|
||||||
await database.put(STORE_NAME, record);
|
await database.put(STORE_NAME, record);
|
||||||
} else {
|
} else {
|
||||||
memoryStore.set(sourceCode, record);
|
memoryStore.set(key, record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isSourceCached(sourceCode: string): Promise<boolean> {
|
export async function isSourceCached(
|
||||||
|
system: string,
|
||||||
|
sourceCode: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const key = scopedKey(system, sourceCode);
|
||||||
const database = await getDb();
|
const database = await getDb();
|
||||||
if (database) {
|
if (database) {
|
||||||
const record = await database.get(STORE_NAME, sourceCode);
|
const record = await database.get(STORE_NAME, key);
|
||||||
return record !== undefined;
|
return record !== undefined;
|
||||||
}
|
}
|
||||||
return memoryStore.has(sourceCode);
|
return memoryStore.has(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCachedSources(): Promise<CachedSourceInfo[]> {
|
export async function getCachedSources(
|
||||||
|
system?: string,
|
||||||
|
): Promise<CachedSourceInfo[]> {
|
||||||
const database = await getDb();
|
const database = await getDb();
|
||||||
|
let records: CachedSourceRecord[];
|
||||||
if (database) {
|
if (database) {
|
||||||
const all: CachedSourceRecord[] = await database.getAll(STORE_NAME);
|
records = await database.getAll(STORE_NAME);
|
||||||
return all.map((r) => ({
|
|
||||||
sourceCode: r.sourceCode,
|
|
||||||
displayName: r.displayName,
|
|
||||||
creatureCount: r.creatureCount,
|
|
||||||
cachedAt: r.cachedAt,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [...memoryStore.values()].map((r) => ({
|
|
||||||
sourceCode: r.sourceCode,
|
|
||||||
displayName: r.displayName,
|
|
||||||
creatureCount: r.creatureCount,
|
|
||||||
cachedAt: r.cachedAt,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearSource(sourceCode: string): Promise<void> {
|
|
||||||
const database = await getDb();
|
|
||||||
if (database) {
|
|
||||||
await database.delete(STORE_NAME, sourceCode);
|
|
||||||
} else {
|
} else {
|
||||||
memoryStore.delete(sourceCode);
|
records = [...memoryStore.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = system
|
||||||
|
? records.filter((r) => r.system === system)
|
||||||
|
: records;
|
||||||
|
return filtered.map((r) => ({
|
||||||
|
sourceCode: r.system
|
||||||
|
? r.sourceCode.slice(r.system.length + 1)
|
||||||
|
: r.sourceCode,
|
||||||
|
displayName: r.displayName,
|
||||||
|
creatureCount: r.creatureCount,
|
||||||
|
cachedAt: r.cachedAt,
|
||||||
|
system: r.system,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSource(
|
||||||
|
system: string,
|
||||||
|
sourceCode: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const key = scopedKey(system, sourceCode);
|
||||||
|
const database = await getDb();
|
||||||
|
if (database) {
|
||||||
|
await database.delete(STORE_NAME, key);
|
||||||
|
} else {
|
||||||
|
memoryStore.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,9 +149,9 @@ export async function clearAll(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAllCachedCreatures(): Promise<
|
export async function loadAllCachedCreatures(): Promise<
|
||||||
Map<CreatureId, Creature>
|
Map<CreatureId, AnyCreature>
|
||||||
> {
|
> {
|
||||||
const map = new Map<CreatureId, Creature>();
|
const map = new Map<CreatureId, AnyCreature>();
|
||||||
const database = await getDb();
|
const database = await getDb();
|
||||||
|
|
||||||
let records: CachedSourceRecord[];
|
let records: CachedSourceRecord[];
|
||||||
|
|||||||
774
apps/web/src/adapters/pf2e-bestiary-adapter.ts
Normal file
774
apps/web/src/adapters/pf2e-bestiary-adapter.ts
Normal file
@@ -0,0 +1,774 @@
|
|||||||
|
import type {
|
||||||
|
CreatureId,
|
||||||
|
EquipmentItem,
|
||||||
|
Pf2eCreature,
|
||||||
|
SpellcastingBlock,
|
||||||
|
SpellReference,
|
||||||
|
TraitBlock,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { stripFoundryTags } from "./strip-foundry-tags.js";
|
||||||
|
|
||||||
|
// -- Raw Foundry VTT types (minimal, for parsing) --
|
||||||
|
|
||||||
|
interface RawFoundryCreature {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
system: {
|
||||||
|
abilities: Record<string, { mod: number }>;
|
||||||
|
attributes: {
|
||||||
|
ac: { value: number; details?: string };
|
||||||
|
hp: { max: number; details?: string };
|
||||||
|
speed: {
|
||||||
|
value: number;
|
||||||
|
otherSpeeds?: { type: string; value: number }[];
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
|
immunities?: { type: string; exceptions?: string[] }[];
|
||||||
|
resistances?: { type: string; value: number; exceptions?: string[] }[];
|
||||||
|
weaknesses?: { type: string; value: number }[];
|
||||||
|
allSaves?: { value: string };
|
||||||
|
};
|
||||||
|
details: {
|
||||||
|
level: { value: number };
|
||||||
|
languages: { value?: string[]; details?: string };
|
||||||
|
publication: { license: string; remaster: boolean; title: string };
|
||||||
|
};
|
||||||
|
perception: {
|
||||||
|
mod: number;
|
||||||
|
details?: string;
|
||||||
|
senses?: { type: string; acuity?: string; range?: number }[];
|
||||||
|
};
|
||||||
|
saves: {
|
||||||
|
fortitude: { value: number; saveDetail?: string };
|
||||||
|
reflex: { value: number; saveDetail?: string };
|
||||||
|
will: { value: number; saveDetail?: string };
|
||||||
|
};
|
||||||
|
skills: Record<string, { base: number; note?: string }>;
|
||||||
|
traits: { rarity: string; size: { value: string }; value: string[] };
|
||||||
|
};
|
||||||
|
items: RawFoundryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawFoundryItem {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
system: Record<string, unknown>;
|
||||||
|
sort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeleeSystem {
|
||||||
|
bonus?: { value: number };
|
||||||
|
damageRolls?: Record<string, { damage: string; damageType: string }>;
|
||||||
|
traits?: { value: string[] };
|
||||||
|
attackEffects?: { value: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionSystem {
|
||||||
|
category?: string;
|
||||||
|
actionType?: { value: string };
|
||||||
|
actions?: { value: number | null };
|
||||||
|
traits?: { value: string[] };
|
||||||
|
description?: { value: string };
|
||||||
|
frequency?: { max: number; per: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpellcastingEntrySystem {
|
||||||
|
tradition?: { value: string };
|
||||||
|
prepared?: { value: string };
|
||||||
|
spelldc?: { dc: number; value?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpellSystem {
|
||||||
|
slug?: string;
|
||||||
|
location?: {
|
||||||
|
value: string;
|
||||||
|
heightenedLevel?: number;
|
||||||
|
uses?: { max: number; value: number };
|
||||||
|
};
|
||||||
|
level?: { value: number };
|
||||||
|
traits?: { rarity?: string; value: string[]; traditions?: string[] };
|
||||||
|
description?: { value: string };
|
||||||
|
range?: { value: string };
|
||||||
|
target?: { value: string };
|
||||||
|
area?: { type?: string; value?: number; details?: string };
|
||||||
|
duration?: { value: string; sustained?: boolean };
|
||||||
|
time?: { value: string };
|
||||||
|
defense?: {
|
||||||
|
save?: { statistic: string; basic?: boolean };
|
||||||
|
passive?: { statistic: string };
|
||||||
|
};
|
||||||
|
heightening?:
|
||||||
|
| {
|
||||||
|
type: "fixed";
|
||||||
|
levels: Record<string, { text?: string }>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "interval";
|
||||||
|
interval: number;
|
||||||
|
damage?: { value: string };
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
overlays?: Record<
|
||||||
|
string,
|
||||||
|
{ name?: string; system?: { description?: { value: string } } }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConsumableSystem {
|
||||||
|
level?: { value: number };
|
||||||
|
traits?: { value: string[] };
|
||||||
|
description?: { value: string };
|
||||||
|
category?: string;
|
||||||
|
spell?: {
|
||||||
|
name: string;
|
||||||
|
system?: { level?: { value: number } };
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EQUIPMENT_TYPES = new Set(["weapon", "consumable", "equipment", "armor"]);
|
||||||
|
|
||||||
|
/** Items shown in the Equipment section with popovers. */
|
||||||
|
function isDetailedEquipment(item: RawFoundryItem): boolean {
|
||||||
|
if (!EQUIPMENT_TYPES.has(item.type)) return false;
|
||||||
|
const sys = item.system;
|
||||||
|
const level = (sys.level as { value: number } | undefined)?.value ?? 0;
|
||||||
|
const traits = (sys.traits as { value: string[] } | undefined)?.value ?? [];
|
||||||
|
// All consumables are tactically relevant (potions, scrolls, poisons, etc.)
|
||||||
|
if (item.type === "consumable") return true;
|
||||||
|
// Magical/invested items
|
||||||
|
if (traits.includes("magical") || traits.includes("invested")) return true;
|
||||||
|
// Special material armor/equipment
|
||||||
|
const material = sys.material as { type: string | null } | undefined;
|
||||||
|
if (material?.type) return true;
|
||||||
|
// Higher-level items
|
||||||
|
if (level > 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Items shown on the "Items" line as plain names. */
|
||||||
|
function isMundaneItem(item: RawFoundryItem): boolean {
|
||||||
|
return EQUIPMENT_TYPES.has(item.type) && !isDetailedEquipment(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEquipmentItem(item: RawFoundryItem): EquipmentItem {
|
||||||
|
const sys = item.system;
|
||||||
|
const level = (sys.level as { value: number } | undefined)?.value ?? 0;
|
||||||
|
const traits = (sys.traits as { value: string[] } | undefined)?.value;
|
||||||
|
const rawDesc = (sys.description as { value: string } | undefined)?.value;
|
||||||
|
const description = rawDesc
|
||||||
|
? stripFoundryTags(rawDesc) || undefined
|
||||||
|
: undefined;
|
||||||
|
const category = sys.category as string | undefined;
|
||||||
|
|
||||||
|
let spellName: string | undefined;
|
||||||
|
let spellRank: number | undefined;
|
||||||
|
if (item.type === "consumable") {
|
||||||
|
const spell = (sys as unknown as ConsumableSystem).spell;
|
||||||
|
if (spell) {
|
||||||
|
spellName = spell.name;
|
||||||
|
spellRank = spell.system?.level?.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
level,
|
||||||
|
category: category || undefined,
|
||||||
|
traits: traits && traits.length > 0 ? traits : undefined,
|
||||||
|
description,
|
||||||
|
spellName,
|
||||||
|
spellRank,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_MAP: Record<string, string> = {
|
||||||
|
tiny: "tiny",
|
||||||
|
sm: "small",
|
||||||
|
med: "medium",
|
||||||
|
lg: "large",
|
||||||
|
huge: "huge",
|
||||||
|
grg: "gargantuan",
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Helpers --
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCreatureId(source: string, name: string): CreatureId {
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
|
return creatureId(`${source}:${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NUMERIC_SLUG = /^(.+)-(\d+)$/;
|
||||||
|
const LETTER_SLUG = /^(.+)-([a-z])$/;
|
||||||
|
|
||||||
|
/** Format rules for traits with a numeric suffix: "reach-10" → "reach 10 feet" */
|
||||||
|
const NUMERIC_TRAIT_FORMATS: Record<string, (n: string) => string> = {
|
||||||
|
reach: (n) => `reach ${n} feet`,
|
||||||
|
range: (n) => `range ${n} feet`,
|
||||||
|
"range-increment": (n) => `range increment ${n} feet`,
|
||||||
|
versatile: (n) => `versatile ${n}`,
|
||||||
|
deadly: (n) => `deadly d${n}`,
|
||||||
|
fatal: (n) => `fatal d${n}`,
|
||||||
|
"fatal-aim": (n) => `fatal aim d${n}`,
|
||||||
|
reload: (n) => `reload ${n}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Format rules for traits with a letter suffix: "versatile-p" → "versatile P" */
|
||||||
|
const LETTER_TRAIT_FORMATS: Record<string, (l: string) => string> = {
|
||||||
|
versatile: (l) => `versatile ${l.toUpperCase()}`,
|
||||||
|
deadly: (l) => `deadly d${l}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Expand slugified trait names: "reach-10" → "reach 10 feet" */
|
||||||
|
function formatTrait(slug: string): string {
|
||||||
|
const numMatch = NUMERIC_SLUG.exec(slug);
|
||||||
|
if (numMatch) {
|
||||||
|
const [, base, num] = numMatch;
|
||||||
|
const fmt = NUMERIC_TRAIT_FORMATS[base];
|
||||||
|
return fmt ? fmt(num) : `${base} ${num}`;
|
||||||
|
}
|
||||||
|
const letterMatch = LETTER_SLUG.exec(slug);
|
||||||
|
if (letterMatch) {
|
||||||
|
const [, base, letter] = letterMatch;
|
||||||
|
const fmt = LETTER_TRAIT_FORMATS[base];
|
||||||
|
if (fmt) return fmt(letter);
|
||||||
|
}
|
||||||
|
return slug.replaceAll("-", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Formatting --
|
||||||
|
|
||||||
|
function formatSenses(
|
||||||
|
senses: { type: string; acuity?: string; range?: number }[] | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!senses || senses.length === 0) return undefined;
|
||||||
|
return senses
|
||||||
|
.map((s) => {
|
||||||
|
const parts = [capitalize(s.type.replaceAll("-", " "))];
|
||||||
|
if (s.acuity && s.acuity !== "precise") {
|
||||||
|
parts.push(`(${s.acuity})`);
|
||||||
|
}
|
||||||
|
if (s.range != null) parts.push(`${s.range} feet`);
|
||||||
|
return parts.join(" ");
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLanguages(
|
||||||
|
languages: { value?: string[]; details?: string } | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!languages?.value || languages.value.length === 0) return undefined;
|
||||||
|
const list = languages.value.map(capitalize).join(", ");
|
||||||
|
return languages.details ? `${list} (${languages.details})` : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSkills(
|
||||||
|
skills: Record<string, { base: number; note?: string }> | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!skills) return undefined;
|
||||||
|
const entries = Object.entries(skills);
|
||||||
|
if (entries.length === 0) return undefined;
|
||||||
|
return entries
|
||||||
|
.map(([name, val]) => {
|
||||||
|
const label = capitalize(name.replaceAll("-", " "));
|
||||||
|
return `${label} +${val.base}`;
|
||||||
|
})
|
||||||
|
.sort()
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImmunities(
|
||||||
|
immunities: { type: string; exceptions?: string[] }[] | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!immunities || immunities.length === 0) return undefined;
|
||||||
|
return immunities
|
||||||
|
.map((i) => {
|
||||||
|
const base = capitalize(i.type.replaceAll("-", " "));
|
||||||
|
if (i.exceptions && i.exceptions.length > 0) {
|
||||||
|
return `${base} (except ${i.exceptions.map((e) => capitalize(e.replaceAll("-", " "))).join(", ")})`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResistances(
|
||||||
|
resistances:
|
||||||
|
| { type: string; value: number; exceptions?: string[] }[]
|
||||||
|
| undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!resistances || resistances.length === 0) return undefined;
|
||||||
|
return resistances
|
||||||
|
.map((r) => {
|
||||||
|
const base = `${capitalize(r.type.replaceAll("-", " "))} ${r.value}`;
|
||||||
|
if (r.exceptions && r.exceptions.length > 0) {
|
||||||
|
return `${base} (except ${r.exceptions.map((e) => capitalize(e.replaceAll("-", " "))).join(", ")})`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWeaknesses(
|
||||||
|
weaknesses: { type: string; value: number }[] | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!weaknesses || weaknesses.length === 0) return undefined;
|
||||||
|
return weaknesses
|
||||||
|
.map((w) => `${capitalize(w.type.replaceAll("-", " "))} ${w.value}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(speed: {
|
||||||
|
value: number;
|
||||||
|
otherSpeeds?: { type: string; value: number }[];
|
||||||
|
details?: string;
|
||||||
|
}): string {
|
||||||
|
const parts = [`${speed.value} feet`];
|
||||||
|
if (speed.otherSpeeds) {
|
||||||
|
for (const s of speed.otherSpeeds) {
|
||||||
|
parts.push(`${capitalize(s.type)} ${s.value} feet`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const base = parts.join(", ");
|
||||||
|
return speed.details ? `${base} (${speed.details})` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Attack normalization --
|
||||||
|
|
||||||
|
/** Format an attack effect slug to display text: "grab" → "Grab", "lich-siphon-life" → "Siphon Life". */
|
||||||
|
function formatAttackEffect(slug: string, creatureName: string): string {
|
||||||
|
const prefix = `${creatureName.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-`;
|
||||||
|
const stripped = slug.startsWith(prefix) ? slug.slice(prefix.length) : slug;
|
||||||
|
return stripped.split("-").map(capitalize).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttack(
|
||||||
|
item: RawFoundryItem,
|
||||||
|
creatureName: string,
|
||||||
|
): TraitBlock {
|
||||||
|
const sys = item.system as unknown as MeleeSystem;
|
||||||
|
const bonus = sys.bonus?.value ?? 0;
|
||||||
|
const traits = sys.traits?.value ?? [];
|
||||||
|
const damageEntries = Object.values(sys.damageRolls ?? {});
|
||||||
|
const damage = damageEntries
|
||||||
|
.map((d) => `${d.damage} ${d.damageType}`)
|
||||||
|
.join(" plus ");
|
||||||
|
const traitStr =
|
||||||
|
traits.length > 0 ? ` (${traits.map(formatTrait).join(", ")})` : "";
|
||||||
|
const effects = sys.attackEffects?.value ?? [];
|
||||||
|
const effectStr =
|
||||||
|
effects.length > 0
|
||||||
|
? ` plus ${effects.map((e) => formatAttackEffect(e, creatureName)).join(" and ")}`
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
name: capitalize(item.name),
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: `+${bonus}${traitStr}, ${damage}${effectStr}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActivity(
|
||||||
|
actionType: string | undefined,
|
||||||
|
actionCount: number | null | undefined,
|
||||||
|
): { number: number; unit: "action" | "free" | "reaction" } | undefined {
|
||||||
|
if (actionType === "action") {
|
||||||
|
return { number: actionCount ?? 1, unit: "action" };
|
||||||
|
}
|
||||||
|
if (actionType === "reaction") {
|
||||||
|
return { number: 1, unit: "reaction" };
|
||||||
|
}
|
||||||
|
if (actionType === "free") {
|
||||||
|
return { number: 1, unit: "free" };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Ability normalization --
|
||||||
|
|
||||||
|
const FREQUENCY_LINE = /(<strong>)?Frequency(<\/strong>)?\s+[^\n]+\n*/i;
|
||||||
|
|
||||||
|
/** Strip the "Frequency once per day" line from ability descriptions when structured frequency data exists. */
|
||||||
|
function stripFrequencyLine(text: string): string {
|
||||||
|
return text.replace(FREQUENCY_LINE, "").trimStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAbility(item: RawFoundryItem): TraitBlock {
|
||||||
|
const sys = item.system as unknown as ActionSystem;
|
||||||
|
const actionType = sys.actionType?.value;
|
||||||
|
const actionCount = sys.actions?.value;
|
||||||
|
let description = stripFoundryTags(sys.description?.value ?? "");
|
||||||
|
const traits = sys.traits?.value ?? [];
|
||||||
|
|
||||||
|
const activity = parseActivity(actionType, actionCount);
|
||||||
|
|
||||||
|
const frequency =
|
||||||
|
sys.frequency?.max != null && sys.frequency.per
|
||||||
|
? `${sys.frequency.max}/${sys.frequency.per}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (frequency) {
|
||||||
|
description = stripFrequencyLine(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
const traitStr =
|
||||||
|
traits.length > 0
|
||||||
|
? `(${traits.map((t) => capitalize(formatTrait(t))).join(", ")}) `
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const text = traitStr ? `${traitStr}${description}` : description;
|
||||||
|
const segments: { type: "text"; value: string }[] = text
|
||||||
|
? [{ type: "text", value: text }]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return { name: item.name, activity, frequency, segments };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Spellcasting normalization --
|
||||||
|
|
||||||
|
function formatRange(range: { value: string } | undefined): string | undefined {
|
||||||
|
if (!range?.value) return undefined;
|
||||||
|
return range.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatArea(
|
||||||
|
area: { type?: string; value?: number; details?: string } | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!area) return undefined;
|
||||||
|
if (area.value && area.type) return `${area.value}-foot ${area.type}`;
|
||||||
|
return area.details ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDefense(defense: SpellSystem["defense"]): string | undefined {
|
||||||
|
if (!defense) return undefined;
|
||||||
|
if (defense.save) {
|
||||||
|
const stat = capitalize(defense.save.statistic);
|
||||||
|
return defense.save.basic ? `basic ${stat}` : stat;
|
||||||
|
}
|
||||||
|
if (defense.passive) return capitalize(defense.passive.statistic);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeightening(
|
||||||
|
heightening: SpellSystem["heightening"],
|
||||||
|
): string | undefined {
|
||||||
|
if (!heightening) return undefined;
|
||||||
|
if (heightening.type === "fixed") {
|
||||||
|
const parts = Object.entries(heightening.levels)
|
||||||
|
.filter(([, lvl]) => lvl.text)
|
||||||
|
.map(
|
||||||
|
([rank, lvl]) =>
|
||||||
|
`Heightened (${rank}) ${stripFoundryTags(lvl.text as string)}`,
|
||||||
|
);
|
||||||
|
return parts.length > 0 ? parts.join("\n") : undefined;
|
||||||
|
}
|
||||||
|
if (heightening.type === "interval") {
|
||||||
|
const dmg = heightening.damage?.value
|
||||||
|
? ` damage increases by ${heightening.damage.value}`
|
||||||
|
: "";
|
||||||
|
return `Heightened (+${heightening.interval})${dmg}`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOverlays(overlays: SpellSystem["overlays"]): string | undefined {
|
||||||
|
if (!overlays) return undefined;
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const overlay of Object.values(overlays)) {
|
||||||
|
const desc = overlay.system?.description?.value;
|
||||||
|
if (!desc) continue;
|
||||||
|
const label = overlay.name ? `${overlay.name}: ` : "";
|
||||||
|
parts.push(`${label}${stripFoundryTags(desc)}`);
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? parts.join("\n") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foundry descriptions often include heightening rules inline at the end.
|
||||||
|
* When we extract heightening into a structured field, strip that trailing
|
||||||
|
* text to avoid duplication.
|
||||||
|
*/
|
||||||
|
const HEIGHTENED_SUFFIX = /\s*Heightened\s*\([^)]*\)[\s\S]*$/;
|
||||||
|
|
||||||
|
function normalizeSpell(
|
||||||
|
item: RawFoundryItem,
|
||||||
|
creatureLevel: number,
|
||||||
|
): SpellReference {
|
||||||
|
const sys = item.system as unknown as SpellSystem;
|
||||||
|
const usesMax = sys.location?.uses?.max;
|
||||||
|
const isCantrip = sys.traits?.value?.includes("cantrip") ?? false;
|
||||||
|
const rank =
|
||||||
|
sys.location?.heightenedLevel ??
|
||||||
|
(isCantrip ? Math.ceil(creatureLevel / 2) : (sys.level?.value ?? 0));
|
||||||
|
const heightening =
|
||||||
|
formatHeightening(sys.heightening) ?? formatOverlays(sys.overlays);
|
||||||
|
|
||||||
|
let description: string | undefined;
|
||||||
|
if (sys.description?.value) {
|
||||||
|
let text = stripFoundryTags(sys.description.value);
|
||||||
|
// Resolve Foundry Roll formula references to the spell's actual rank.
|
||||||
|
// The parenthesized form (e.g., "(@item.level)d4") is most common.
|
||||||
|
text = text.replaceAll(/\(?@item\.(?:rank|level)\)?/g, String(rank));
|
||||||
|
if (heightening) {
|
||||||
|
text = text.replace(HEIGHTENED_SUFFIX, "").trim();
|
||||||
|
}
|
||||||
|
description = text || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
slug: sys.slug,
|
||||||
|
rank,
|
||||||
|
description,
|
||||||
|
traits: sys.traits?.value,
|
||||||
|
traditions: sys.traits?.traditions,
|
||||||
|
range: formatRange(sys.range),
|
||||||
|
target: sys.target?.value || undefined,
|
||||||
|
area: formatArea(sys.area),
|
||||||
|
duration: sys.duration?.value || undefined,
|
||||||
|
defense: formatDefense(sys.defense),
|
||||||
|
actionCost: sys.time?.value || undefined,
|
||||||
|
heightening,
|
||||||
|
usesPerDay: usesMax && usesMax > 1 ? usesMax : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSpellcastingEntry(
|
||||||
|
entry: RawFoundryItem,
|
||||||
|
allSpells: readonly RawFoundryItem[],
|
||||||
|
creatureLevel: number,
|
||||||
|
): SpellcastingBlock {
|
||||||
|
const sys = entry.system as unknown as SpellcastingEntrySystem;
|
||||||
|
const tradition = capitalize(sys.tradition?.value ?? "");
|
||||||
|
const prepared = sys.prepared?.value ?? "";
|
||||||
|
const dc = sys.spelldc?.dc ?? 0;
|
||||||
|
const attack = sys.spelldc?.value ?? 0;
|
||||||
|
|
||||||
|
const name = entry.name || `${tradition} ${capitalize(prepared)} Spells`;
|
||||||
|
const headerText = `DC ${dc}${attack ? `, attack +${attack}` : ""}`;
|
||||||
|
|
||||||
|
const linkedSpells = allSpells.filter(
|
||||||
|
(s) => (s.system as unknown as SpellSystem).location?.value === entry._id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const byRank = new Map<number, SpellReference[]>();
|
||||||
|
const cantrips: SpellReference[] = [];
|
||||||
|
|
||||||
|
for (const spell of linkedSpells) {
|
||||||
|
const ref = normalizeSpell(spell, creatureLevel);
|
||||||
|
const isCantrip =
|
||||||
|
(spell.system as unknown as SpellSystem).traits?.value?.includes(
|
||||||
|
"cantrip",
|
||||||
|
) ?? false;
|
||||||
|
if (isCantrip) {
|
||||||
|
cantrips.push(ref);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rank = ref.rank ?? 0;
|
||||||
|
const existing = byRank.get(rank) ?? [];
|
||||||
|
existing.push(ref);
|
||||||
|
byRank.set(rank, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const daily = [...byRank.entries()]
|
||||||
|
.sort(([a], [b]) => b - a)
|
||||||
|
.map(([rank, spells]) => ({
|
||||||
|
uses: rank,
|
||||||
|
each: true,
|
||||||
|
spells,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
headerText,
|
||||||
|
atWill: orUndefined(cantrips),
|
||||||
|
daily: orUndefined(daily),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSpellcasting(
|
||||||
|
items: readonly RawFoundryItem[],
|
||||||
|
creatureLevel: number,
|
||||||
|
): SpellcastingBlock[] {
|
||||||
|
const entries = items.filter((i) => i.type === "spellcastingEntry");
|
||||||
|
const spells = items.filter((i) => i.type === "spell");
|
||||||
|
return entries.map((entry) =>
|
||||||
|
normalizeSpellcastingEntry(entry, spells, creatureLevel),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Main normalization --
|
||||||
|
|
||||||
|
function orUndefined<T>(arr: T[]): T[] | undefined {
|
||||||
|
return arr.length > 0 ? arr : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build display traits: [rarity (if not common), size, ...type traits] */
|
||||||
|
function buildTraits(traits: {
|
||||||
|
rarity: string;
|
||||||
|
size: { value: string };
|
||||||
|
value: string[];
|
||||||
|
}): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
if (traits.rarity && traits.rarity !== "common") {
|
||||||
|
result.push(traits.rarity);
|
||||||
|
}
|
||||||
|
const size = SIZE_MAP[traits.size.value] ?? "medium";
|
||||||
|
result.push(size);
|
||||||
|
result.push(...traits.value);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEALING_GLOSSARY =
|
||||||
|
/^<p>@Localize\[PF2E\.NPC\.Abilities\.Glossary\.(FastHealing|Regeneration|NegativeHealing)\]/;
|
||||||
|
|
||||||
|
/** Glossary-only abilities that duplicate structured data shown elsewhere. */
|
||||||
|
const REDUNDANT_GLOSSARY =
|
||||||
|
/^<p>@Localize\[PF2E\.NPC\.Abilities\.Glossary\.(ConstantSpells|AtWillSpells)\]/;
|
||||||
|
|
||||||
|
const STRIP_GLOSSARY_AND_P = /<p>@Localize\[[^\]]+\]<\/p>|<\/?p>/g;
|
||||||
|
|
||||||
|
/** True when the description has no user-visible content beyond glossary tags. */
|
||||||
|
function isGlossaryOnly(desc: string | undefined): boolean {
|
||||||
|
if (!desc) return true;
|
||||||
|
return desc.replace(STRIP_GLOSSARY_AND_P, "").trim() === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRedundantAbility(
|
||||||
|
item: RawFoundryItem,
|
||||||
|
excludeName: string | undefined,
|
||||||
|
hpDetails: string | undefined,
|
||||||
|
): boolean {
|
||||||
|
const sys = item.system as unknown as ActionSystem;
|
||||||
|
const desc = sys.description?.value;
|
||||||
|
// Ability duplicates the allSaves line — suppress only if glossary-only
|
||||||
|
if (excludeName && item.name.toLowerCase() === excludeName.toLowerCase()) {
|
||||||
|
return isGlossaryOnly(desc);
|
||||||
|
}
|
||||||
|
if (!desc) return false;
|
||||||
|
// Healing/regen glossary when hp.details already shows the info
|
||||||
|
if (hpDetails && HEALING_GLOSSARY.test(desc)) return true;
|
||||||
|
// Spell mechanic glossary reminders shown in the spellcasting section
|
||||||
|
if (REDUNDANT_GLOSSARY.test(desc)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionsByCategory(
|
||||||
|
items: readonly RawFoundryItem[],
|
||||||
|
category: string,
|
||||||
|
excludeName?: string,
|
||||||
|
hpDetails?: string,
|
||||||
|
): TraitBlock[] {
|
||||||
|
return items
|
||||||
|
.filter(
|
||||||
|
(a) =>
|
||||||
|
a.type === "action" &&
|
||||||
|
(a.system as unknown as ActionSystem).category === category &&
|
||||||
|
!isRedundantAbility(a, excludeName, hpDetails),
|
||||||
|
)
|
||||||
|
.map(normalizeAbility);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAbilityMods(
|
||||||
|
mods: Record<string, { mod: number }>,
|
||||||
|
): Pf2eCreature["abilityMods"] {
|
||||||
|
return {
|
||||||
|
str: mods.str?.mod ?? 0,
|
||||||
|
dex: mods.dex?.mod ?? 0,
|
||||||
|
con: mods.con?.mod ?? 0,
|
||||||
|
int: mods.int?.mod ?? 0,
|
||||||
|
wis: mods.wis?.mod ?? 0,
|
||||||
|
cha: mods.cha?.mod ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFoundryCreature(
|
||||||
|
raw: unknown,
|
||||||
|
sourceCode?: string,
|
||||||
|
sourceDisplayName?: string,
|
||||||
|
): Pf2eCreature {
|
||||||
|
const r = raw as RawFoundryCreature;
|
||||||
|
const sys = r.system;
|
||||||
|
const publication = sys.details?.publication;
|
||||||
|
|
||||||
|
const source = sourceCode ?? publication?.title ?? "";
|
||||||
|
const items = r.items ?? [];
|
||||||
|
const allSavesText = sys.attributes.allSaves?.value ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
system: "pf2e",
|
||||||
|
id: makeCreatureId(source, r.name),
|
||||||
|
name: r.name,
|
||||||
|
source,
|
||||||
|
sourceDisplayName: sourceDisplayName ?? publication?.title ?? "",
|
||||||
|
level: sys.details?.level?.value ?? 0,
|
||||||
|
traits: buildTraits(sys.traits),
|
||||||
|
perception: sys.perception?.mod ?? 0,
|
||||||
|
perceptionDetails: sys.perception?.details || undefined,
|
||||||
|
senses: formatSenses(sys.perception?.senses),
|
||||||
|
languages: formatLanguages(sys.details?.languages),
|
||||||
|
skills: formatSkills(sys.skills),
|
||||||
|
abilityMods: extractAbilityMods(sys.abilities ?? {}),
|
||||||
|
ac: sys.attributes.ac.value,
|
||||||
|
acConditional: sys.attributes.ac.details || undefined,
|
||||||
|
saveFort: sys.saves.fortitude.value,
|
||||||
|
saveRef: sys.saves.reflex.value,
|
||||||
|
saveWill: sys.saves.will.value,
|
||||||
|
saveConditional: allSavesText || undefined,
|
||||||
|
hp: sys.attributes.hp.max,
|
||||||
|
hpDetails: sys.attributes.hp.details || undefined,
|
||||||
|
immunities: formatImmunities(sys.attributes.immunities),
|
||||||
|
resistances: formatResistances(sys.attributes.resistances),
|
||||||
|
weaknesses: formatWeaknesses(sys.attributes.weaknesses),
|
||||||
|
speed: formatSpeed(sys.attributes.speed),
|
||||||
|
attacks: orUndefined(
|
||||||
|
items
|
||||||
|
.filter((i) => i.type === "melee")
|
||||||
|
.map((i) => normalizeAttack(i, r.name)),
|
||||||
|
),
|
||||||
|
abilitiesTop: orUndefined(actionsByCategory(items, "interaction")),
|
||||||
|
abilitiesMid: orUndefined(
|
||||||
|
actionsByCategory(
|
||||||
|
items,
|
||||||
|
"defensive",
|
||||||
|
allSavesText || undefined,
|
||||||
|
sys.attributes.hp.details || undefined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
|
||||||
|
spellcasting: orUndefined(
|
||||||
|
normalizeSpellcasting(items, sys.details?.level?.value ?? 0),
|
||||||
|
),
|
||||||
|
items:
|
||||||
|
items
|
||||||
|
.filter(isMundaneItem)
|
||||||
|
.map((i) => i.name)
|
||||||
|
.join(", ") || undefined,
|
||||||
|
equipment: orUndefined(
|
||||||
|
items.filter(isDetailedEquipment).map(normalizeEquipmentItem),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFoundryCreatures(
|
||||||
|
rawCreatures: unknown[],
|
||||||
|
sourceCode?: string,
|
||||||
|
sourceDisplayName?: string,
|
||||||
|
): Pf2eCreature[] {
|
||||||
|
return rawCreatures.map((raw) =>
|
||||||
|
normalizeFoundryCreature(raw, sourceCode, sourceDisplayName),
|
||||||
|
);
|
||||||
|
}
|
||||||
87
apps/web/src/adapters/pf2e-bestiary-index-adapter.ts
Normal file
87
apps/web/src/adapters/pf2e-bestiary-index-adapter.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type {
|
||||||
|
Pf2eBestiaryIndex,
|
||||||
|
Pf2eBestiaryIndexEntry,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
|
||||||
|
import rawIndex from "../../../../data/bestiary/pf2e-index.json";
|
||||||
|
|
||||||
|
interface CompactCreature {
|
||||||
|
readonly n: string;
|
||||||
|
readonly s: string;
|
||||||
|
readonly lv: number;
|
||||||
|
readonly ac: number;
|
||||||
|
readonly hp: number;
|
||||||
|
readonly pc: number;
|
||||||
|
readonly sz: string;
|
||||||
|
readonly tp: string;
|
||||||
|
readonly f: string;
|
||||||
|
readonly li: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompactIndex {
|
||||||
|
readonly sources: Record<string, string>;
|
||||||
|
readonly creatures: readonly CompactCreature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCreature(c: CompactCreature): Pf2eBestiaryIndexEntry {
|
||||||
|
return {
|
||||||
|
name: c.n,
|
||||||
|
source: c.s,
|
||||||
|
level: c.lv,
|
||||||
|
ac: c.ac,
|
||||||
|
hp: c.hp,
|
||||||
|
perception: c.pc,
|
||||||
|
size: c.sz,
|
||||||
|
type: c.tp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedIndex: Pf2eBestiaryIndex | undefined;
|
||||||
|
|
||||||
|
export function loadPf2eBestiaryIndex(): Pf2eBestiaryIndex {
|
||||||
|
if (cachedIndex) return cachedIndex;
|
||||||
|
|
||||||
|
const compact = rawIndex as unknown as CompactIndex;
|
||||||
|
cachedIndex = {
|
||||||
|
sources: compact.sources,
|
||||||
|
creatures: compact.creatures.map(mapCreature),
|
||||||
|
};
|
||||||
|
return cachedIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllPf2eSourceCodes(): string[] {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
return Object.keys(index.sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultPf2eFetchUrl(
|
||||||
|
_sourceCode: string,
|
||||||
|
baseUrl?: string,
|
||||||
|
): string {
|
||||||
|
if (baseUrl !== undefined) {
|
||||||
|
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||||
|
}
|
||||||
|
return "https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCreaturePathsForSource(sourceCode: string): string[] {
|
||||||
|
const compact = rawIndex as unknown as CompactIndex;
|
||||||
|
return compact.creatures.filter((c) => c.s === sourceCode).map((c) => c.f);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCreatureNamesByPaths(paths: string[]): Map<string, string> {
|
||||||
|
const compact = rawIndex as unknown as CompactIndex;
|
||||||
|
const pathSet = new Set(paths);
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
for (const c of compact.creatures) {
|
||||||
|
if (pathSet.has(c.f)) {
|
||||||
|
result.set(c.f, c.n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPf2eSourceDisplayName(sourceCode: string): string {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
return index.sources[sourceCode] ?? sourceCode;
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AnyCreature,
|
||||||
BestiaryIndex,
|
BestiaryIndex,
|
||||||
Creature,
|
|
||||||
CreatureId,
|
CreatureId,
|
||||||
Encounter,
|
Encounter,
|
||||||
|
Pf2eBestiaryIndex,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
UndoRedoState,
|
UndoRedoState,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
@@ -31,15 +32,16 @@ export interface CachedSourceInfo {
|
|||||||
|
|
||||||
export interface BestiaryCachePort {
|
export interface BestiaryCachePort {
|
||||||
cacheSource(
|
cacheSource(
|
||||||
|
system: string,
|
||||||
sourceCode: string,
|
sourceCode: string,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
creatures: Creature[],
|
creatures: AnyCreature[],
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
isSourceCached(sourceCode: string): Promise<boolean>;
|
isSourceCached(system: string, sourceCode: string): Promise<boolean>;
|
||||||
getCachedSources(): Promise<CachedSourceInfo[]>;
|
getCachedSources(system?: string): Promise<CachedSourceInfo[]>;
|
||||||
clearSource(sourceCode: string): Promise<void>;
|
clearSource(system: string, sourceCode: string): Promise<void>;
|
||||||
clearAll(): Promise<void>;
|
clearAll(): Promise<void>;
|
||||||
loadAllCachedCreatures(): Promise<Map<CreatureId, Creature>>;
|
loadAllCachedCreatures(): Promise<Map<CreatureId, AnyCreature>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BestiaryIndexPort {
|
export interface BestiaryIndexPort {
|
||||||
@@ -48,3 +50,12 @@ export interface BestiaryIndexPort {
|
|||||||
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||||
getSourceDisplayName(sourceCode: string): string;
|
getSourceDisplayName(sourceCode: string): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Pf2eBestiaryIndexPort {
|
||||||
|
loadIndex(): Pf2eBestiaryIndex;
|
||||||
|
getAllSourceCodes(): string[];
|
||||||
|
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||||
|
getSourceDisplayName(sourceCode: string): string;
|
||||||
|
getCreaturePathsForSource(sourceCode: string): string[];
|
||||||
|
getCreatureNamesByPaths(paths: string[]): Map<string, string>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "../persistence/undo-redo-storage.js";
|
} from "../persistence/undo-redo-storage.js";
|
||||||
import * as bestiaryCache from "./bestiary-cache.js";
|
import * as bestiaryCache from "./bestiary-cache.js";
|
||||||
import * as bestiaryIndex from "./bestiary-index-adapter.js";
|
import * as bestiaryIndex from "./bestiary-index-adapter.js";
|
||||||
|
import * as pf2eBestiaryIndex from "./pf2e-bestiary-index-adapter.js";
|
||||||
|
|
||||||
export const productionAdapters: Adapters = {
|
export const productionAdapters: Adapters = {
|
||||||
encounterPersistence: {
|
encounterPersistence: {
|
||||||
@@ -41,4 +42,12 @@ export const productionAdapters: Adapters = {
|
|||||||
getDefaultFetchUrl: bestiaryIndex.getDefaultFetchUrl,
|
getDefaultFetchUrl: bestiaryIndex.getDefaultFetchUrl,
|
||||||
getSourceDisplayName: bestiaryIndex.getSourceDisplayName,
|
getSourceDisplayName: bestiaryIndex.getSourceDisplayName,
|
||||||
},
|
},
|
||||||
|
pf2eBestiaryIndex: {
|
||||||
|
loadIndex: pf2eBestiaryIndex.loadPf2eBestiaryIndex,
|
||||||
|
getAllSourceCodes: pf2eBestiaryIndex.getAllPf2eSourceCodes,
|
||||||
|
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
|
||||||
|
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
|
||||||
|
getCreaturePathsForSource: pf2eBestiaryIndex.getCreaturePathsForSource,
|
||||||
|
getCreatureNamesByPaths: pf2eBestiaryIndex.getCreatureNamesByPaths,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
110
apps/web/src/adapters/strip-foundry-tags.ts
Normal file
110
apps/web/src/adapters/strip-foundry-tags.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Strips Foundry VTT HTML descriptions with enrichment syntax to plain
|
||||||
|
* readable text. Handles @Damage, @Check, @UUID, @Template and generic
|
||||||
|
* @Tag patterns as well as common HTML elements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// -- Enrichment-param helpers --
|
||||||
|
|
||||||
|
function formatDamage(params: string): string {
|
||||||
|
// "3d6+10[fire]" → "3d6+10 fire"
|
||||||
|
// "d4[persistent,fire]" → "d4 persistent fire"
|
||||||
|
return params
|
||||||
|
.replaceAll(
|
||||||
|
/\[([^\]]*)\]/g,
|
||||||
|
(_, type: string) => ` ${type.replaceAll(",", " ")}`,
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCheck(params: string): string {
|
||||||
|
// "reflex|dc:33|basic" → "DC 33 basic Reflex"
|
||||||
|
const parts = params.split("|");
|
||||||
|
const type = parts[0] ?? "";
|
||||||
|
let dc = "";
|
||||||
|
let basic = false;
|
||||||
|
for (const part of parts.slice(1)) {
|
||||||
|
if (part.startsWith("dc:")) {
|
||||||
|
dc = part.slice(3);
|
||||||
|
} else if (part === "basic") {
|
||||||
|
basic = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const label = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
const dcStr = dc ? `DC ${dc} ` : "";
|
||||||
|
const basicStr = basic ? "basic " : "";
|
||||||
|
return `${dcStr}${basicStr}${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTemplate(params: string): string {
|
||||||
|
// "cone|distance:40" → "40-foot cone"
|
||||||
|
const parts = params.split("|");
|
||||||
|
const shape = parts[0] ?? "";
|
||||||
|
let distance = "";
|
||||||
|
for (const part of parts.slice(1)) {
|
||||||
|
if (part.startsWith("distance:")) {
|
||||||
|
distance = part.slice(9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return distance ? `${distance}-foot ${shape}` : shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripFoundryTags(html: string): string {
|
||||||
|
if (typeof html !== "string") return String(html);
|
||||||
|
let result = html;
|
||||||
|
|
||||||
|
// Strip Foundry enrichment tags (with optional display text)
|
||||||
|
// @Tag[params]{display} → display (prefer display text)
|
||||||
|
// @Tag[params] → extracted content
|
||||||
|
|
||||||
|
// @Damage has nested brackets: @Damage[3d6+10[fire]]
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@Damage\[((?:[^[\]]|\[[^\]]*\])*)\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, params: string, display: string | undefined) =>
|
||||||
|
display ?? formatDamage(params),
|
||||||
|
);
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@Check\[([^\]]+)\](?:\{([^}]*)\})?/g,
|
||||||
|
(_, params: string) => formatCheck(params),
|
||||||
|
);
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@UUID\[[^\]]+?([^./\]]+)\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, lastSegment: string, display: string | undefined) =>
|
||||||
|
display ?? lastSegment,
|
||||||
|
);
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@Template\[([^\]]+)\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, params: string, display: string | undefined) =>
|
||||||
|
display ?? formatTemplate(params),
|
||||||
|
);
|
||||||
|
// Catch-all for unknown @Tag patterns
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@\w+\[[^\]]*\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, display: string | undefined) => display ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Strip action-glyph spans (content is a number the renderer handles)
|
||||||
|
result = result.replaceAll(/<span class="action-glyph">[^<]*<\/span>/gi, "");
|
||||||
|
|
||||||
|
// Strip HTML tags (preserve <strong> for UI rendering)
|
||||||
|
result = result.replaceAll(/<br\s*\/?>/gi, "\n");
|
||||||
|
result = result.replaceAll(/<hr\s*\/?>/gi, "\n");
|
||||||
|
result = result.replaceAll(/<\/p>\s*<p[^>]*>/gi, "\n");
|
||||||
|
result = result.replaceAll(/<(?!\/?(?:strong|em|ul|ol|li)\b)[^>]+>/g, "");
|
||||||
|
|
||||||
|
// Decode common HTML entities
|
||||||
|
result = result.replaceAll("&", "&");
|
||||||
|
result = result.replaceAll("<", "<");
|
||||||
|
result = result.replaceAll(">", ">");
|
||||||
|
result = result.replaceAll(""", '"');
|
||||||
|
|
||||||
|
// Collapse whitespace around list tags so they don't create extra
|
||||||
|
// line breaks when rendered with whitespace-pre-line
|
||||||
|
result = result.replaceAll(/\s*(<\/?(?:ul|ol)>)\s*/g, "$1");
|
||||||
|
result = result.replaceAll(/\s*(<\/?li>)\s*/g, "$1");
|
||||||
|
|
||||||
|
// Collapse whitespace
|
||||||
|
result = result.replaceAll(/[ \t]+/g, " ");
|
||||||
|
result = result.replaceAll(/\n\s*\n/g, "\n");
|
||||||
|
return result.trim();
|
||||||
|
}
|
||||||
@@ -98,20 +98,26 @@ export function stripTags(text: string): string {
|
|||||||
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
|
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
|
||||||
// Covers: spell, condition, damage, dice, variantrule, action, skill,
|
// Covers: spell, condition, damage, dice, variantrule, action, skill,
|
||||||
// creature, hazard, status, plus any unknown tags
|
// creature, hazard, status, plus any unknown tags
|
||||||
|
// Run in a loop to resolve nested tags (e.g. {@b ... {@spell fireball} ...})
|
||||||
|
// from innermost to outermost.
|
||||||
|
const tagPattern = /\{@(\w+)\s+([^}]+)\}/g;
|
||||||
|
while (tagPattern.test(result)) {
|
||||||
result = result.replaceAll(
|
result = result.replaceAll(
|
||||||
/\{@(\w+)\s+([^}]+)\}/g,
|
tagPattern,
|
||||||
(_, tag: string, content: string) => {
|
(_, tag: string, content: string) => {
|
||||||
// For tags with Display|Source format, extract first segment
|
|
||||||
const segments = content.split("|");
|
const segments = content.split("|");
|
||||||
|
|
||||||
// Some tags have a third segment as display text: {@variantrule Name|Source|Display}
|
if (
|
||||||
if ((tag === "variantrule" || tag === "action") && segments.length >= 3) {
|
(tag === "variantrule" || tag === "action") &&
|
||||||
|
segments.length >= 3
|
||||||
|
) {
|
||||||
return segments[2];
|
return segments[2];
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments[0];
|
return segments[0];
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||||
|
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||||
import { BulkImportPrompt } from "../bulk-import-prompt.js";
|
import { BulkImportPrompt } from "../bulk-import-prompt.js";
|
||||||
|
|
||||||
const THREE_SOURCES_REGEX = /3 sources/;
|
const THREE_SOURCES_REGEX = /3 sources/;
|
||||||
@@ -68,9 +69,11 @@ function createAdaptersWithSources() {
|
|||||||
function renderWithAdapters() {
|
function renderWithAdapters() {
|
||||||
const adapters = createAdaptersWithSources();
|
const adapters = createAdaptersWithSources();
|
||||||
return render(
|
return render(
|
||||||
|
<RulesEditionProvider>
|
||||||
<AdapterProvider adapters={adapters}>
|
<AdapterProvider adapters={adapters}>
|
||||||
<BulkImportPrompt />
|
<BulkImportPrompt />
|
||||||
</AdapterProvider>,
|
</AdapterProvider>
|
||||||
|
</RulesEditionProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +1,92 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
import {
|
||||||
|
type ConditionEntry,
|
||||||
|
type ConditionId,
|
||||||
|
getConditionsForEdition,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PersistentDamageType,
|
||||||
|
type RulesEdition,
|
||||||
|
} from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { createRef, type RefObject } from "react";
|
import { createRef, type ReactNode, type RefObject, useEffect } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { RulesEditionProvider } from "../../contexts/index.js";
|
import { RulesEditionProvider } from "../../contexts/index.js";
|
||||||
|
import { useRulesEditionContext } from "../../contexts/rules-edition-context.js";
|
||||||
import { ConditionPicker } from "../condition-picker";
|
import { ConditionPicker } from "../condition-picker";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function EditionSetter({
|
||||||
|
edition,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
edition: RulesEdition;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { setEdition } = useRulesEditionContext();
|
||||||
|
useEffect(() => {
|
||||||
|
setEdition(edition);
|
||||||
|
}, [edition, setEdition]);
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function renderPicker(
|
function renderPicker(
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
activeConditions: readonly ConditionId[];
|
activeConditions: readonly ConditionEntry[];
|
||||||
|
activePersistentDamage: readonly PersistentDamageEntry[];
|
||||||
onToggle: (conditionId: ConditionId) => void;
|
onToggle: (conditionId: ConditionId) => 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 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}
|
||||||
|
onAddPersistentDamage={onAddPersistentDamage}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
|
</EditionSetter>
|
||||||
</RulesEditionProvider>,
|
</RulesEditionProvider>,
|
||||||
);
|
);
|
||||||
return { ...result, onToggle, onClose };
|
return { ...result, onToggle, onSetValue, onAddPersistentDamage, onClose };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ConditionPicker", () => {
|
describe("ConditionPicker", () => {
|
||||||
it("renders all condition definitions from domain", () => {
|
it("renders edition-specific conditions from domain", () => {
|
||||||
renderPicker();
|
renderPicker();
|
||||||
for (const def of CONDITION_DEFINITIONS) {
|
const editionConditions = getConditionsForEdition("5.5e");
|
||||||
|
for (const def of editionConditions) {
|
||||||
expect(screen.getByText(def.label)).toBeInTheDocument();
|
expect(screen.getByText(def.label)).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("active conditions are visually distinguished", () => {
|
it("active conditions are visually distinguished", () => {
|
||||||
renderPicker({ activeConditions: ["blinded"] });
|
renderPicker({ activeConditions: [{ id: "blinded" }] });
|
||||||
const blindedButton = screen.getByText("Blinded").closest("button");
|
const row = screen.getByText("Blinded").closest("div[class]");
|
||||||
expect(blindedButton?.className).toContain("bg-card/50");
|
expect(row?.className).toContain("bg-card/50");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clicking a condition calls onToggle with that condition's ID", async () => {
|
it("clicking a condition calls onToggle with that condition's ID", async () => {
|
||||||
@@ -65,8 +103,115 @@ describe("ConditionPicker", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("active condition labels use foreground color", () => {
|
it("active condition labels use foreground color", () => {
|
||||||
renderPicker({ activeConditions: ["charmed"] });
|
renderPicker({ activeConditions: [{ id: "charmed" }] });
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import type { ConditionId } from "@initiative/domain";
|
import type { ConditionEntry } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -14,6 +14,7 @@ function renderTags(props: Partial<Parameters<typeof ConditionTags>[0]> = {}) {
|
|||||||
<ConditionTags
|
<ConditionTags
|
||||||
conditions={props.conditions}
|
conditions={props.conditions}
|
||||||
onRemove={props.onRemove ?? (() => {})}
|
onRemove={props.onRemove ?? (() => {})}
|
||||||
|
onDecrement={props.onDecrement ?? (() => {})}
|
||||||
onOpenPicker={props.onOpenPicker ?? (() => {})}
|
onOpenPicker={props.onOpenPicker ?? (() => {})}
|
||||||
/>
|
/>
|
||||||
</RulesEditionProvider>,
|
</RulesEditionProvider>,
|
||||||
@@ -28,7 +29,7 @@ describe("ConditionTags", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders a button per condition", () => {
|
it("renders a button per condition", () => {
|
||||||
const conditions: ConditionId[] = ["blinded", "prone"];
|
const conditions: ConditionEntry[] = [{ id: "blinded" }, { id: "prone" }];
|
||||||
renderTags({ conditions });
|
renderTags({ conditions });
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||||
@@ -39,7 +40,7 @@ describe("ConditionTags", () => {
|
|||||||
it("calls onRemove with condition id when clicked", async () => {
|
it("calls onRemove with condition id when clicked", async () => {
|
||||||
const onRemove = vi.fn();
|
const onRemove = vi.fn();
|
||||||
renderTags({
|
renderTags({
|
||||||
conditions: ["blinded"] as ConditionId[],
|
conditions: [{ id: "blinded" }] as ConditionEntry[],
|
||||||
onRemove,
|
onRemove,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,4 +67,37 @@ describe("ConditionTags", () => {
|
|||||||
// Only add button
|
// Only add button
|
||||||
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
|
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("displays value badge for valued conditions", () => {
|
||||||
|
renderTags({ conditions: [{ id: "frightened", value: 3 }] });
|
||||||
|
expect(screen.getByText("3")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onDecrement for valued condition click", async () => {
|
||||||
|
const onDecrement = vi.fn();
|
||||||
|
renderTags({
|
||||||
|
conditions: [{ id: "frightened", value: 2 }],
|
||||||
|
onDecrement,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Remove Frightened" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onDecrement).toHaveBeenCalledWith("frightened");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onRemove for non-valued condition click", async () => {
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
renderTags({
|
||||||
|
conditions: [{ id: "blinded" }],
|
||||||
|
onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onRemove).toHaveBeenCalledWith("blinded");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
|
|
||||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
|
cleanup,
|
||||||
|
render,
|
||||||
|
renderHook,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
@@ -13,6 +19,7 @@ import {
|
|||||||
buildEncounter,
|
buildEncounter,
|
||||||
} 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 { DifficultyBreakdownPanel } from "../difficulty-breakdown-panel.js";
|
import { DifficultyBreakdownPanel } from "../difficulty-breakdown-panel.js";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -121,7 +128,7 @@ describe("DifficultyBreakdownPanel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders bestiary combatant as read-only with source name", async () => {
|
it("shows PC in party column with level", async () => {
|
||||||
renderPanel({
|
renderPanel({
|
||||||
encounter: defaultEncounter(),
|
encounter: defaultEncounter(),
|
||||||
playerCharacters: defaultPCs,
|
playerCharacters: defaultPCs,
|
||||||
@@ -129,12 +136,53 @@ describe("DifficultyBreakdownPanel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Goblin (SRD)")).toBeInTheDocument();
|
expect(screen.getByText("Hero")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Lv 5")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows monsters in enemy column", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("CR 1/4").length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText("CR 1/4").length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders custom combatant with CR picker", async () => {
|
it("renders explanation text", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"Allied NPC XP is subtracted from encounter difficulty",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Net Monster XP footer", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Net Monster XP")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders custom combatant with CR picker in enemy column", async () => {
|
||||||
renderPanel({
|
renderPanel({
|
||||||
encounter: defaultEncounter(),
|
encounter: defaultEncounter(),
|
||||||
playerCharacters: defaultPCs,
|
playerCharacters: defaultPCs,
|
||||||
@@ -144,27 +192,10 @@ describe("DifficultyBreakdownPanel", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const pickers = screen.getAllByLabelText("Challenge rating");
|
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||||
expect(pickers).toHaveLength(2);
|
expect(pickers).toHaveLength(2);
|
||||||
// First picker is "Custom Thug" with CR 2
|
|
||||||
expect(pickers[0]).toHaveValue("2");
|
expect(pickers[0]).toHaveValue("2");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders unassigned combatant with Assign picker and dash for XP", async () => {
|
|
||||||
renderPanel({
|
|
||||||
encounter: defaultEncounter(),
|
|
||||||
playerCharacters: defaultPCs,
|
|
||||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const pickers = screen.getAllByLabelText("Challenge rating");
|
|
||||||
// Second picker is "Bandit" with no CR
|
|
||||||
expect(pickers[1]).toHaveValue("");
|
|
||||||
// "—" appears for unassigned XP
|
|
||||||
expect(screen.getByText("—")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selecting a CR updates the visible XP value", async () => {
|
it("selecting a CR updates the visible XP value", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderPanel({
|
renderPanel({
|
||||||
@@ -173,24 +204,19 @@ describe("DifficultyBreakdownPanel", () => {
|
|||||||
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for the panel to render with bestiary data
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("—")).toBeInTheDocument();
|
expect(screen.getAllByLabelText("Challenge rating")).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The Bandit (second picker) has no CR — shows "—" for XP
|
|
||||||
const pickers = screen.getAllByLabelText("Challenge rating");
|
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||||
|
|
||||||
// Select CR 5 (1,800 XP) on Bandit
|
|
||||||
await user.selectOptions(pickers[1], "5");
|
await user.selectOptions(pickers[1], "5");
|
||||||
|
|
||||||
// XP should update — the "—" should be replaced with an XP value
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("1,800")).toBeInTheDocument();
|
expect(screen.getByText("1,800")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders total monster XP", async () => {
|
it("non-PC combatants show toggle button", async () => {
|
||||||
renderPanel({
|
renderPanel({
|
||||||
encounter: defaultEncounter(),
|
encounter: defaultEncounter(),
|
||||||
playerCharacters: defaultPCs,
|
playerCharacters: defaultPCs,
|
||||||
@@ -198,12 +224,57 @@ describe("DifficultyBreakdownPanel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Total Monster XP")).toBeInTheDocument();
|
// Each non-PC enemy combatant has a toggle button
|
||||||
|
expect(
|
||||||
|
screen.getByLabelText("Move Goblin to party side"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByLabelText("Move Custom Thug to party side"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PC combatants do not show side toggle", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Hero")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByLabelText("Move Hero to enemy side"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("side toggle moves combatant between sections", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle goblin to party side
|
||||||
|
const toggleBtn = screen.getByLabelText("Move Goblin to party side");
|
||||||
|
await user.click(toggleBtn);
|
||||||
|
|
||||||
|
// After toggle, the aria-label should change to "Move Goblin to enemy side"
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByLabelText("Move Goblin to enemy side"),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders nothing when breakdown data is insufficient", () => {
|
it("renders nothing when breakdown data is insufficient", () => {
|
||||||
// No PCs with level → breakdown returns null
|
|
||||||
const { container } = renderPanel({
|
const { container } = renderPanel({
|
||||||
encounter: buildEncounter({
|
encounter: buildEncounter({
|
||||||
combatants: [
|
combatants: [
|
||||||
@@ -215,6 +286,63 @@ describe("DifficultyBreakdownPanel", () => {
|
|||||||
expect(container.innerHTML).toBe("");
|
expect(container.innerHTML).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows 4 threshold columns for 2014 edition", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("5e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Easy:", { exact: false })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Med:", { exact: false })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Hard:", { exact: false })).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Deadly:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows multiplier and adjusted XP for 2014 edition", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("5e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Monster XP")).toBeInTheDocument();
|
||||||
|
// 1 PC (<3) triggers party size adjustment
|
||||||
|
expect(screen.getByText("Adjusted for 1 PC")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Net Monster XP for 5.5e edition (no multiplier)", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Net Monster XP")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("calls onClose when Escape is pressed", async () => {
|
it("calls onClose when Escape is pressed", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import type { DifficultyResult } from "@initiative/domain";
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DifficultyIndicator } from "../difficulty-indicator.js";
|
import {
|
||||||
|
DifficultyIndicator,
|
||||||
|
TIER_LABELS_5_5E,
|
||||||
|
TIER_LABELS_2014,
|
||||||
|
} from "../difficulty-indicator.js";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -11,50 +15,77 @@ function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
|
|||||||
return {
|
return {
|
||||||
tier,
|
tier,
|
||||||
totalMonsterXp: 100,
|
totalMonsterXp: 100,
|
||||||
partyBudget: { low: 50, moderate: 100, high: 200 },
|
thresholds: [
|
||||||
|
{ label: "Low", value: 50 },
|
||||||
|
{ label: "Moderate", value: 100 },
|
||||||
|
{ label: "High", value: 200 },
|
||||||
|
],
|
||||||
|
encounterMultiplier: undefined,
|
||||||
|
adjustedXp: undefined,
|
||||||
|
partySizeAdjusted: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("DifficultyIndicator", () => {
|
describe("DifficultyIndicator", () => {
|
||||||
it("renders 3 bars", () => {
|
it("renders 3 bars", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<DifficultyIndicator result={makeResult("moderate")} />,
|
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||||
);
|
);
|
||||||
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||||
expect(bars).toHaveLength(3);
|
expect(bars).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows 'Trivial encounter difficulty' label for trivial tier", () => {
|
it("shows 'Trivial encounter difficulty' for 5.5e tier 0", () => {
|
||||||
render(<DifficultyIndicator result={makeResult("trivial")} />);
|
render(
|
||||||
|
<DifficultyIndicator result={makeResult(0)} labels={TIER_LABELS_5_5E} />,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("img", {
|
screen.getByRole("img", { name: "Trivial encounter difficulty" }),
|
||||||
name: "Trivial encounter difficulty",
|
|
||||||
}),
|
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows 'Low encounter difficulty' label for low tier", () => {
|
it("shows 'Low encounter difficulty' for 5.5e tier 1", () => {
|
||||||
render(<DifficultyIndicator result={makeResult("low")} />);
|
render(
|
||||||
|
<DifficultyIndicator result={makeResult(1)} labels={TIER_LABELS_5_5E} />,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("img", { name: "Low encounter difficulty" }),
|
screen.getByRole("img", { name: "Low encounter difficulty" }),
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows 'Moderate encounter difficulty' label for moderate tier", () => {
|
it("shows 'Moderate encounter difficulty' for 5.5e tier 2", () => {
|
||||||
render(<DifficultyIndicator result={makeResult("moderate")} />);
|
render(
|
||||||
|
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("img", {
|
screen.getByRole("img", { name: "Moderate encounter difficulty" }),
|
||||||
name: "Moderate encounter difficulty",
|
|
||||||
}),
|
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows 'High encounter difficulty' label for high tier", () => {
|
it("shows 'High encounter difficulty' for 5.5e tier 3", () => {
|
||||||
render(<DifficultyIndicator result={makeResult("high")} />);
|
render(
|
||||||
|
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_5_5E} />,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("img", {
|
screen.getByRole("img", { name: "High encounter difficulty" }),
|
||||||
name: "High encounter difficulty",
|
).toBeDefined();
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
it("shows 'Easy encounter difficulty' for 2014 tier 0", () => {
|
||||||
|
render(
|
||||||
|
<DifficultyIndicator result={makeResult(0)} labels={TIER_LABELS_2014} />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", { name: "Easy encounter difficulty" }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Deadly encounter difficulty' for 2014 tier 3", () => {
|
||||||
|
render(
|
||||||
|
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_2014} />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", { name: "Deadly encounter difficulty" }),
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,22 +94,21 @@ describe("DifficultyIndicator", () => {
|
|||||||
const handleClick = vi.fn();
|
const handleClick = vi.fn();
|
||||||
render(
|
render(
|
||||||
<DifficultyIndicator
|
<DifficultyIndicator
|
||||||
result={makeResult("moderate")}
|
result={makeResult(2)}
|
||||||
|
labels={TIER_LABELS_5_5E}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(
|
await user.click(
|
||||||
screen.getByRole("img", {
|
screen.getByRole("img", { name: "Moderate encounter difficulty" }),
|
||||||
name: "Moderate encounter difficulty",
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
expect(handleClick).toHaveBeenCalledOnce();
|
expect(handleClick).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders as div when onClick not provided", () => {
|
it("renders as div when onClick not provided", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<DifficultyIndicator result={makeResult("moderate")} />,
|
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||||
);
|
);
|
||||||
const element = container.querySelector("[role='img']");
|
const element = container.querySelector("[role='img']");
|
||||||
expect(element?.tagName).toBe("DIV");
|
expect(element?.tagName).toBe("DIV");
|
||||||
@@ -87,7 +117,8 @@ describe("DifficultyIndicator", () => {
|
|||||||
it("renders as button when onClick provided", () => {
|
it("renders as button when onClick provided", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<DifficultyIndicator
|
<DifficultyIndicator
|
||||||
result={makeResult("moderate")}
|
result={makeResult(2)}
|
||||||
|
labels={TIER_LABELS_5_5E}
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { EquipmentItem } from "@initiative/domain";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { EquipmentDetailPopover } from "../equipment-detail-popover.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const POISON: EquipmentItem = {
|
||||||
|
name: "Giant Wasp Venom",
|
||||||
|
level: 7,
|
||||||
|
category: "poison",
|
||||||
|
traits: ["consumable", "poison", "injury"],
|
||||||
|
description: "A deadly poison extracted from giant wasps.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCROLL: EquipmentItem = {
|
||||||
|
name: "Scroll of Teleport",
|
||||||
|
level: 11,
|
||||||
|
category: "scroll",
|
||||||
|
traits: ["consumable", "magical", "scroll"],
|
||||||
|
description: "A scroll containing Teleport.",
|
||||||
|
spellName: "Teleport",
|
||||||
|
spellRank: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANCHOR: DOMRect = new DOMRect(100, 100, 50, 20);
|
||||||
|
const SCROLL_SPELL_REGEX = /Teleport \(Rank 6\)/;
|
||||||
|
const DIALOG_LABEL_REGEX = /Equipment details: Giant Wasp Venom/;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation(() => ({
|
||||||
|
matches: true,
|
||||||
|
media: "(min-width: 1024px)",
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EquipmentDetailPopover", () => {
|
||||||
|
it("renders item name, level, traits, and description", () => {
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={POISON}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Giant Wasp Venom")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("7")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("consumable")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("poison")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("injury")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("A deadly poison extracted from giant wasps."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders scroll/wand spell info", () => {
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={SCROLL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(SCROLL_SPELL_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when Escape is pressed", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={POISON}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the dialog role with the item name as label", () => {
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={POISON}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", {
|
||||||
|
name: DIALOG_LABEL_REGEX,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PersistentDamagePicker } from "../persistent-damage-picker.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderPicker(
|
||||||
|
overrides: Partial<{
|
||||||
|
activeEntries: { type: string; formula: string }[];
|
||||||
|
onAdd: (damageType: string, formula: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const onAdd = overrides.onAdd ?? vi.fn();
|
||||||
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
|
const result = render(
|
||||||
|
<PersistentDamagePicker
|
||||||
|
activeEntries={
|
||||||
|
(overrides.activeEntries as Parameters<
|
||||||
|
typeof PersistentDamagePicker
|
||||||
|
>[0]["activeEntries"]) ?? undefined
|
||||||
|
}
|
||||||
|
onAdd={onAdd as Parameters<typeof PersistentDamagePicker>[0]["onAdd"]}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { ...result, onAdd, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PersistentDamagePicker", () => {
|
||||||
|
it("renders damage type dropdown and formula input", () => {
|
||||||
|
renderPicker();
|
||||||
|
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText("2d6")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirm button is disabled when formula is empty", () => {
|
||||||
|
renderPicker();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Add persistent damage" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submitting calls onAdd with selected type and formula", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onAdd } = renderPicker();
|
||||||
|
await user.type(screen.getByPlaceholderText("2d6"), "3d6");
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Add persistent damage" }),
|
||||||
|
);
|
||||||
|
expect(onAdd).toHaveBeenCalledWith("fire", "3d6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter in formula input confirms", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onAdd } = renderPicker();
|
||||||
|
await user.type(screen.getByPlaceholderText("2d6"), "2d6{Enter}");
|
||||||
|
expect(onAdd).toHaveBeenCalledWith("fire", "2d6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pre-fills formula for existing active entry", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPicker({
|
||||||
|
activeEntries: [{ type: "fire", formula: "2d6" }],
|
||||||
|
});
|
||||||
|
expect(screen.getByPlaceholderText("2d6")).toHaveValue("2d6");
|
||||||
|
|
||||||
|
// Change type to one without active entry
|
||||||
|
await user.selectOptions(screen.getByRole("combobox"), "bleed");
|
||||||
|
expect(screen.getByPlaceholderText("2d6")).toHaveValue("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PersistentDamageEntry,
|
||||||
|
PersistentDamageType,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PersistentDamageTags } from "../persistent-damage-tags.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderTags(
|
||||||
|
entries: readonly PersistentDamageEntry[] | undefined,
|
||||||
|
onRemove = vi.fn(),
|
||||||
|
) {
|
||||||
|
const result = render(
|
||||||
|
<PersistentDamageTags entries={entries} onRemove={onRemove} />,
|
||||||
|
);
|
||||||
|
return { ...result, onRemove };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PersistentDamageTags", () => {
|
||||||
|
it("renders nothing when entries undefined", () => {
|
||||||
|
const { container } = renderTags(undefined);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when entries is empty array", () => {
|
||||||
|
const { container } = renderTags([]);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders tag per entry with icon and formula text", () => {
|
||||||
|
renderTags([
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
]);
|
||||||
|
expect(screen.getByText("2d6")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1d4")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("click calls onRemove with correct damage type", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onRemove } = renderTags([{ type: "fire", formula: "2d6" }]);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Remove persistent Fire damage",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onRemove).toHaveBeenCalledWith(
|
||||||
|
"fire" satisfies PersistentDamageType,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tooltip shows full description", () => {
|
||||||
|
renderTags([{ type: "fire", formula: "2d6" }]);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Remove persistent Fire damage",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
497
apps/web/src/components/__tests__/pf2e-stat-block.test.tsx
Normal file
497
apps/web/src/components/__tests__/pf2e-stat-block.test.tsx
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { Pf2eCreature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Pf2eStatBlock } from "../pf2e-stat-block.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const USES_PER_DAY_REGEX = /×3/;
|
||||||
|
const HEAL_DESCRIPTION_REGEX = /channel positive energy/;
|
||||||
|
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
|
||||||
|
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
|
||||||
|
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
|
||||||
|
const SAVE_CONDITIONAL_ABSENT_REGEX = /status to all saves/;
|
||||||
|
const HP_DETAILS_REGEX = /115.*regeneration 20/;
|
||||||
|
const REGEN_REGEX = /regeneration/;
|
||||||
|
const ATTACK_NAME_REGEX = /Dogslicer/;
|
||||||
|
const ATTACK_DAMAGE_REGEX = /1d6 slashing/;
|
||||||
|
const SPELLCASTING_ENTRY_REGEX = /Divine Innate Spells\./;
|
||||||
|
const ABILITY_MID_NAME_REGEX = /Goblin Scuttle/;
|
||||||
|
const ABILITY_MID_DESC_REGEX = /The goblin Steps\./;
|
||||||
|
const CANTRIPS_REGEX = /Cantrips:/;
|
||||||
|
const AC_REGEX = /16/;
|
||||||
|
const RK_DC_13_REGEX = /DC 13/;
|
||||||
|
const RK_DC_15_REGEX = /DC 15/;
|
||||||
|
const RK_DC_25_REGEX = /DC 25/;
|
||||||
|
const RK_HUMANOID_SOCIETY_REGEX = /Humanoid \(Society\)/;
|
||||||
|
const RK_UNDEAD_RELIGION_REGEX = /Undead \(Religion\)/;
|
||||||
|
const RK_BEAST_SKILLS_REGEX = /Beast \(Arcana\/Nature\)/;
|
||||||
|
const SCROLL_NAME_REGEX = /Scroll of Teleport/;
|
||||||
|
|
||||||
|
const GOBLIN_WARRIOR: Pf2eCreature = {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("pathfinder-monster-core:goblin-warrior"),
|
||||||
|
name: "Goblin Warrior",
|
||||||
|
source: "pathfinder-monster-core",
|
||||||
|
sourceDisplayName: "Monster Core",
|
||||||
|
level: -1,
|
||||||
|
traits: ["small", "goblin", "humanoid"],
|
||||||
|
perception: 2,
|
||||||
|
senses: "Darkvision",
|
||||||
|
languages: "Common, Goblin",
|
||||||
|
skills: "Acrobatics +5, Athletics +2, Nature +1, Stealth +5",
|
||||||
|
abilityMods: { str: 0, dex: 3, con: 1, int: 0, wis: -1, cha: 1 },
|
||||||
|
ac: 16,
|
||||||
|
saveFort: 5,
|
||||||
|
saveRef: 7,
|
||||||
|
saveWill: 3,
|
||||||
|
hp: 6,
|
||||||
|
speed: "25 feet",
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "Dogslicer",
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: "+7 (agile, backstabber, finesse), 1d6 slashing",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
abilitiesMid: [
|
||||||
|
{
|
||||||
|
name: "Goblin Scuttle",
|
||||||
|
activity: { number: 1, unit: "reaction" },
|
||||||
|
segments: [{ type: "text", value: "The goblin Steps." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAUNET: Pf2eCreature = {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("pathfinder-monster-core-2:naunet"),
|
||||||
|
name: "Naunet",
|
||||||
|
source: "pathfinder-monster-core-2",
|
||||||
|
sourceDisplayName: "Monster Core 2",
|
||||||
|
level: 7,
|
||||||
|
traits: ["large", "monitor", "protean"],
|
||||||
|
perception: 14,
|
||||||
|
senses: "Darkvision",
|
||||||
|
languages: "Chthonian, Empyrean, Protean",
|
||||||
|
skills:
|
||||||
|
"Acrobatics +14, Athletics +16, Intimidation +16, Stealth +14, Survival +12",
|
||||||
|
abilityMods: { str: 5, dex: 3, con: 5, int: 0, wis: 3, cha: 3 },
|
||||||
|
ac: 24,
|
||||||
|
saveFort: 18,
|
||||||
|
saveRef: 14,
|
||||||
|
saveWill: 12,
|
||||||
|
saveConditional: "+1 status to all saves vs. magic",
|
||||||
|
hp: 120,
|
||||||
|
resistances: "Precision 5, Protean anatomy 10",
|
||||||
|
speed: "25 feet, Fly 30 feet, Swim 25 feet (unfettered movement)",
|
||||||
|
spellcasting: [
|
||||||
|
{
|
||||||
|
name: "Divine Innate Spells",
|
||||||
|
headerText: "DC 25, attack +17",
|
||||||
|
daily: [
|
||||||
|
{
|
||||||
|
uses: 4,
|
||||||
|
each: true,
|
||||||
|
spells: [{ name: "Unfettered Movement (Constant)" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
atWill: [{ name: "Detect Magic" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TROLL: Pf2eCreature = {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("pathfinder-monster-core:forest-troll"),
|
||||||
|
name: "Forest Troll",
|
||||||
|
source: "pathfinder-monster-core",
|
||||||
|
sourceDisplayName: "Monster Core",
|
||||||
|
level: 5,
|
||||||
|
traits: ["large", "giant", "troll"],
|
||||||
|
perception: 11,
|
||||||
|
senses: "Darkvision",
|
||||||
|
languages: "Jotun",
|
||||||
|
skills: "Athletics +12, Intimidation +12",
|
||||||
|
abilityMods: { str: 5, dex: 2, con: 6, int: -2, wis: 0, cha: -2 },
|
||||||
|
ac: 20,
|
||||||
|
saveFort: 17,
|
||||||
|
saveRef: 11,
|
||||||
|
saveWill: 7,
|
||||||
|
hp: 115,
|
||||||
|
hpDetails: "regeneration 20 (deactivated by acid or fire)",
|
||||||
|
weaknesses: "Fire 10",
|
||||||
|
speed: "30 feet",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderStatBlock(creature: Pf2eCreature) {
|
||||||
|
return render(<Pf2eStatBlock creature={creature} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Pf2eStatBlock", () => {
|
||||||
|
describe("header", () => {
|
||||||
|
it("renders creature name and level", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Goblin Warrior" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Level -1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders traits as tags", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Small")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Humanoid")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders source display name", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Monster Core")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recall knowledge", () => {
|
||||||
|
it("renders Recall Knowledge line for a creature with a recognized type trait", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Recall Knowledge")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(RK_DC_13_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(RK_HUMANOID_SOCIETY_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts DC for uncommon rarity", () => {
|
||||||
|
const uncommonCreature: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
traits: ["uncommon", "small", "humanoid"],
|
||||||
|
};
|
||||||
|
renderStatBlock(uncommonCreature);
|
||||||
|
expect(screen.getByText(RK_DC_15_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts DC for rare rarity", () => {
|
||||||
|
const rareCreature: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
level: 5,
|
||||||
|
traits: ["rare", "medium", "undead"],
|
||||||
|
};
|
||||||
|
renderStatBlock(rareCreature);
|
||||||
|
expect(screen.getByText(RK_DC_25_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(RK_UNDEAD_RELIGION_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows multiple skills for types with dual skill mapping", () => {
|
||||||
|
const beastCreature: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
traits: ["small", "beast"],
|
||||||
|
};
|
||||||
|
renderStatBlock(beastCreature);
|
||||||
|
expect(screen.getByText(RK_BEAST_SKILLS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits Recall Knowledge when no type trait is recognized", () => {
|
||||||
|
const noTypeCreature: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
traits: ["small", "goblin"],
|
||||||
|
};
|
||||||
|
renderStatBlock(noTypeCreature);
|
||||||
|
expect(screen.queryByText("Recall Knowledge")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("perception and senses", () => {
|
||||||
|
it("renders perception modifier and senses", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Perception")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(PERCEPTION_SENSES_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders languages", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Languages")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Common, Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders skills", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Skills")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(SKILLS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ability modifiers", () => {
|
||||||
|
it("renders all six ability labels", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
for (const label of ["Str", "Dex", "Con", "Int", "Wis", "Cha"]) {
|
||||||
|
expect(screen.getByText(label)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders positive and negative modifiers", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("+3")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("-1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defenses", () => {
|
||||||
|
it("renders AC and saves", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("AC")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(AC_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Fort")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Ref")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Will")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders HP", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("HP")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("6")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders saveConditional inline with saves", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText(SAVE_CONDITIONAL_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits saveConditional when absent", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(
|
||||||
|
screen.queryByText(SAVE_CONDITIONAL_ABSENT_REGEX),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders hpDetails in parentheses after HP", () => {
|
||||||
|
renderStatBlock(TROLL);
|
||||||
|
expect(screen.getByText(HP_DETAILS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits hpDetails when absent", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.queryByText(REGEN_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders resistances and weaknesses", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText("Resistances")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Precision 5, Protean anatomy 10"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("abilities", () => {
|
||||||
|
it("renders mid (defensive) abilities", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText(ABILITY_MID_NAME_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(ABILITY_MID_DESC_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("speed and attacks", () => {
|
||||||
|
it("renders speed", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Speed")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("25 feet")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders attacks", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText(ATTACK_NAME_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(ATTACK_DAMAGE_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("spellcasting", () => {
|
||||||
|
it("renders spellcasting entry with header", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText(SPELLCASTING_ENTRY_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("DC 25, attack +17")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders ranked spells", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText("Rank 4:")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Unfettered Movement (Constant)"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders cantrips", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText("Cantrips:")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Detect Magic")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits spellcasting when absent", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.queryByText(CANTRIPS_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("equipment section", () => {
|
||||||
|
const CREATURE_WITH_EQUIPMENT: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
id: creatureId("test:equipped"),
|
||||||
|
name: "Equipped NPC",
|
||||||
|
items: "longsword, leather armor",
|
||||||
|
equipment: [
|
||||||
|
{
|
||||||
|
name: "Giant Wasp Venom",
|
||||||
|
level: 7,
|
||||||
|
category: "poison",
|
||||||
|
traits: ["consumable", "poison"],
|
||||||
|
description: "A deadly poison extracted from giant wasps.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Scroll of Teleport",
|
||||||
|
level: 11,
|
||||||
|
category: "scroll",
|
||||||
|
traits: ["consumable", "magical", "scroll"],
|
||||||
|
description: "A scroll containing Teleport.",
|
||||||
|
spellName: "Teleport",
|
||||||
|
spellRank: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Plain Talisman",
|
||||||
|
level: 1,
|
||||||
|
traits: ["magical"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders Equipment section with item names", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Equipment" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Giant Wasp Venom")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders scroll name as-is from Foundry data", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(screen.getByText(SCROLL_NAME_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render Equipment section when creature has no equipment", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Equipment" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders equipment items with descriptions as clickable buttons", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Giant Wasp Venom" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders equipment items without descriptions as plain text", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Plain Talisman" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Plain Talisman")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Items line with mundane item names", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(screen.getByText("Items")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("longsword, leather armor")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clickable spells", () => {
|
||||||
|
const SPELLCASTER: Pf2eCreature = {
|
||||||
|
...NAUNET,
|
||||||
|
id: creatureId("test:spellcaster"),
|
||||||
|
name: "Spellcaster",
|
||||||
|
spellcasting: [
|
||||||
|
{
|
||||||
|
name: "Divine Innate Spells",
|
||||||
|
headerText: "DC 30, attack +20",
|
||||||
|
atWill: [{ name: "Detect Magic", rank: 1 }],
|
||||||
|
daily: [
|
||||||
|
{
|
||||||
|
uses: 4,
|
||||||
|
each: true,
|
||||||
|
spells: [
|
||||||
|
{
|
||||||
|
name: "Heal",
|
||||||
|
description: "You channel positive energy to heal.",
|
||||||
|
rank: 4,
|
||||||
|
usesPerDay: 3,
|
||||||
|
},
|
||||||
|
{ name: "Restoration", rank: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation(() => ({
|
||||||
|
matches: true,
|
||||||
|
media: "(min-width: 1024px)",
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a spell with a description as a clickable button", () => {
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
expect(screen.getByRole("button", { name: "Heal" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a spell without description as plain text (not a button)", () => {
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Restoration" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Restoration")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders usesPerDay as plain text alongside the spell button", () => {
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
expect(screen.getByText(USES_PER_DAY_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the spell popover when a spell button is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Heal" }));
|
||||||
|
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the popover when Escape is pressed", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Heal" }));
|
||||||
|
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
expect(
|
||||||
|
screen.queryByText(HEAL_DESCRIPTION_REGEX),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,14 +37,18 @@ function renderModal(open = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("SettingsModal", () => {
|
describe("SettingsModal", () => {
|
||||||
it("renders edition toggle buttons", () => {
|
it("renders game system section with all three options", () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
|
expect(screen.getByText("Game System")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "5e (2014)" }),
|
screen.getByRole("button", { name: "5e (2014)" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "5.5e (2024)" }),
|
screen.getByRole("button", { name: "5.5e (2024)" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Pathfinder 2e" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders theme toggle buttons", () => {
|
it("renders theme toggle buttons", () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||||
|
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||||
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
|
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
|
||||||
|
|
||||||
const MONSTER_MANUAL_REGEX = /Monster Manual/;
|
const MONSTER_MANUAL_REGEX = /Monster Manual/;
|
||||||
@@ -36,12 +37,14 @@ function renderPrompt(sourceCode = "MM") {
|
|||||||
code === "MM" ? "Monster Manual" : code,
|
code === "MM" ? "Monster Manual" : code,
|
||||||
};
|
};
|
||||||
const result = render(
|
const result = render(
|
||||||
|
<RulesEditionProvider>
|
||||||
<AdapterProvider adapters={adapters}>
|
<AdapterProvider adapters={adapters}>
|
||||||
<SourceFetchPrompt
|
<SourceFetchPrompt
|
||||||
sourceCode={sourceCode}
|
sourceCode={sourceCode}
|
||||||
onSourceLoaded={onSourceLoaded}
|
onSourceLoaded={onSourceLoaded}
|
||||||
/>
|
/>
|
||||||
</AdapterProvider>,
|
</AdapterProvider>
|
||||||
|
</RulesEditionProvider>,
|
||||||
);
|
);
|
||||||
return { ...result, onSourceLoaded };
|
return { ...result, onSourceLoaded };
|
||||||
}
|
}
|
||||||
@@ -62,7 +65,7 @@ describe("SourceFetchPrompt", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("Load calls fetchAndCacheSource and onSourceLoaded on success", async () => {
|
it("Load calls fetchAndCacheSource and onSourceLoaded on success", async () => {
|
||||||
mockFetchAndCacheSource.mockResolvedValueOnce(undefined);
|
mockFetchAndCacheSource.mockResolvedValueOnce({ skippedNames: [] });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { onSourceLoaded } = renderPrompt();
|
const { onSourceLoaded } = renderPrompt();
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ beforeAll(() => {
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
function renderWithSources(sources: CachedSourceInfo[] = []) {
|
function renderWithSources(sources: CachedSourceInfo[] = []): void {
|
||||||
const adapters = createTestAdapters();
|
const adapters = createTestAdapters();
|
||||||
// Wire getCachedSources to return the provided sources initially,
|
// Wire getCachedSources to return the provided sources initially,
|
||||||
// then empty after clear operations
|
// then empty after clear operations
|
||||||
@@ -36,7 +36,7 @@ function renderWithSources(sources: CachedSourceInfo[] = []) {
|
|||||||
adapters.bestiaryCache = {
|
adapters.bestiaryCache = {
|
||||||
...adapters.bestiaryCache,
|
...adapters.bestiaryCache,
|
||||||
getCachedSources: () => Promise.resolve(currentSources),
|
getCachedSources: () => Promise.resolve(currentSources),
|
||||||
clearSource(sourceCode) {
|
clearSource(_system, sourceCode) {
|
||||||
currentSources = currentSources.filter(
|
currentSources = currentSources.filter(
|
||||||
(s) => s.sourceCode !== sourceCode,
|
(s) => s.sourceCode !== sourceCode,
|
||||||
);
|
);
|
||||||
@@ -57,14 +57,14 @@ function renderWithSources(sources: CachedSourceInfo[] = []) {
|
|||||||
|
|
||||||
describe("SourceManager", () => {
|
describe("SourceManager", () => {
|
||||||
it("shows 'No cached sources' empty state when no sources", async () => {
|
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||||
void renderWithSources([]);
|
renderWithSources([]);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists cached sources with display name and creature count", async () => {
|
it("lists cached sources with display name and creature count", async () => {
|
||||||
void renderWithSources([
|
renderWithSources([
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
@@ -88,7 +88,7 @@ describe("SourceManager", () => {
|
|||||||
|
|
||||||
it("Clear All button removes all sources", async () => {
|
it("Clear All button removes all sources", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
void renderWithSources([
|
renderWithSources([
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
@@ -110,7 +110,7 @@ describe("SourceManager", () => {
|
|||||||
|
|
||||||
it("individual source delete button removes that source", async () => {
|
it("individual source delete button removes that source", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
void renderWithSources([
|
renderWithSources([
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
|
|||||||
158
apps/web/src/components/__tests__/spell-detail-popover.test.tsx
Normal file
158
apps/web/src/components/__tests__/spell-detail-popover.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { SpellReference } from "@initiative/domain";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SpellDetailPopover } from "../spell-detail-popover.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const FIREBALL: SpellReference = {
|
||||||
|
name: "Fireball",
|
||||||
|
slug: "fireball",
|
||||||
|
rank: 3,
|
||||||
|
description: "A spark leaps from your fingertip to the target.",
|
||||||
|
traits: ["fire", "manipulate"],
|
||||||
|
traditions: ["arcane", "primal"],
|
||||||
|
range: "500 feet",
|
||||||
|
area: "20-foot burst",
|
||||||
|
defense: "basic Reflex",
|
||||||
|
actionCost: "2",
|
||||||
|
heightening: "Heightened (+1) The damage increases by 2d6.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANCHOR: DOMRect = new DOMRect(100, 100, 50, 20);
|
||||||
|
|
||||||
|
const SPARK_LEAPS_REGEX = /spark leaps/;
|
||||||
|
const HEIGHTENED_REGEX = /Heightened.*2d6/;
|
||||||
|
const RANGE_REGEX = /500 feet/;
|
||||||
|
const AREA_REGEX = /20-foot burst/;
|
||||||
|
const DEFENSE_REGEX = /basic Reflex/;
|
||||||
|
const NO_DESCRIPTION_REGEX = /No description available/;
|
||||||
|
const DIALOG_LABEL_REGEX = /Spell details: Fireball/;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Force desktop variant in jsdom
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation(() => ({
|
||||||
|
matches: true,
|
||||||
|
media: "(min-width: 1024px)",
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SpellDetailPopover", () => {
|
||||||
|
it("renders spell name, rank, traits, and description", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Fireball")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("3rd")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("fire")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("manipulate")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(SPARK_LEAPS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders heightening rules when present", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(HEIGHTENED_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders range, area, and defense", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(RANGE_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(AREA_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(DEFENSE_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when Escape is pressed", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows placeholder when description is missing", () => {
|
||||||
|
const spell: SpellReference = { name: "Mystery", rank: 1 };
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={spell}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(NO_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the action cost as an icon when it is a numeric action count", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Action cost "2" renders as an SVG ActivityIcon (portaled to body)
|
||||||
|
const dialog = screen.getByRole("dialog");
|
||||||
|
expect(dialog.querySelector("svg")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders non-numeric action cost as text", () => {
|
||||||
|
const spell: SpellReference = {
|
||||||
|
...FIREBALL,
|
||||||
|
actionCost: "1 minute",
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={spell}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("1 minute")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the dialog role with the spell name as label", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", { name: DIALOG_LABEL_REGEX }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import type { Creature } from "@initiative/domain";
|
|||||||
import { creatureId } from "@initiative/domain";
|
import { creatureId } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { StatBlock } from "../stat-block.js";
|
import { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -46,10 +46,30 @@ const GOBLIN: Creature = {
|
|||||||
skills: "Stealth +6",
|
skills: "Stealth +6",
|
||||||
senses: "darkvision 60 ft., passive Perception 9",
|
senses: "darkvision 60 ft., passive Perception 9",
|
||||||
languages: "Common, Goblin",
|
languages: "Common, Goblin",
|
||||||
traits: [{ name: "Nimble Escape", text: "Disengage or Hide as bonus." }],
|
traits: [
|
||||||
actions: [{ name: "Scimitar", text: "Melee: +4 to hit, 5 slashing." }],
|
{
|
||||||
bonusActions: [{ name: "Nimble", text: "Disengage or Hide." }],
|
name: "Nimble Escape",
|
||||||
reactions: [{ name: "Redirect", text: "Redirect attack to ally." }],
|
segments: [{ type: "text", value: "Disengage or Hide as bonus." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
name: "Scimitar",
|
||||||
|
segments: [{ type: "text", value: "Melee: +4 to hit, 5 slashing." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bonusActions: [
|
||||||
|
{
|
||||||
|
name: "Nimble",
|
||||||
|
segments: [{ type: "text", value: "Disengage or Hide." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reactions: [
|
||||||
|
{
|
||||||
|
name: "Redirect",
|
||||||
|
segments: [{ type: "text", value: "Redirect attack to ally." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const DRAGON: Creature = {
|
const DRAGON: Creature = {
|
||||||
@@ -75,17 +95,31 @@ const DRAGON: Creature = {
|
|||||||
legendaryActions: {
|
legendaryActions: {
|
||||||
preamble: "The dragon can take 3 legendary actions.",
|
preamble: "The dragon can take 3 legendary actions.",
|
||||||
entries: [
|
entries: [
|
||||||
{ name: "Detect", text: "Wisdom (Perception) check." },
|
{
|
||||||
{ name: "Tail Attack", text: "Tail attack." },
|
name: "Detect",
|
||||||
|
segments: [
|
||||||
|
{ type: "text" as const, value: "Wisdom (Perception) check." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Tail Attack",
|
||||||
|
segments: [{ type: "text" as const, value: "Tail attack." }],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
spellcasting: [
|
spellcasting: [
|
||||||
{
|
{
|
||||||
name: "Innate Spellcasting",
|
name: "Innate Spellcasting",
|
||||||
headerText: "The dragon's spellcasting ability is Charisma.",
|
headerText: "The dragon's spellcasting ability is Charisma.",
|
||||||
atWill: ["detect magic", "suggestion"],
|
atWill: [{ name: "detect magic" }, { name: "suggestion" }],
|
||||||
daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }],
|
daily: [
|
||||||
restLong: [{ uses: 1, each: false, spells: ["wish"] }],
|
{
|
||||||
|
uses: 3,
|
||||||
|
each: true,
|
||||||
|
spells: [{ name: "fireball" }, { name: "wall of fire" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
restLong: [{ uses: 1, each: false, spells: [{ name: "wish" }] }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,23 +3,30 @@ import { useId, useState } from "react";
|
|||||||
import { useAdapters } from "../contexts/adapter-context.js";
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
const DEFAULT_BASE_URL =
|
const DND_BASE_URL =
|
||||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||||
|
|
||||||
|
const PF2E_BASE_URL =
|
||||||
|
"https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/";
|
||||||
|
|
||||||
export function BulkImportPrompt() {
|
export function BulkImportPrompt() {
|
||||||
const { bestiaryIndex } = useAdapters();
|
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||||
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
||||||
useBestiaryContext();
|
useBestiaryContext();
|
||||||
const { state: importState, startImport, reset } = useBulkImportContext();
|
const { state: importState, startImport, reset } = useBulkImportContext();
|
||||||
const { dismissPanel } = useSidePanelContext();
|
const { dismissPanel } = useSidePanelContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
|
||||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
||||||
|
const defaultUrl = edition === "pf2e" ? PF2E_BASE_URL : DND_BASE_URL;
|
||||||
|
const [baseUrl, setBaseUrl] = useState(defaultUrl);
|
||||||
const baseUrlId = useId();
|
const baseUrlId = useId();
|
||||||
const totalSources = bestiaryIndex.getAllSourceCodes().length;
|
const totalSources = indexPort.getAllSourceCodes().length;
|
||||||
|
|
||||||
const handleStart = (url: string) => {
|
const handleStart = (url: string) => {
|
||||||
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
type CombatantId,
|
type CombatantId,
|
||||||
type ConditionId,
|
type ConditionEntry,
|
||||||
type CreatureId,
|
type CreatureId,
|
||||||
deriveHpStatus,
|
deriveHpStatus,
|
||||||
|
type PersistentDamageEntry,
|
||||||
type PlayerIcon,
|
type PlayerIcon,
|
||||||
type RollMode,
|
type RollMode,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
@@ -10,6 +11,7 @@ import { Brain, Pencil, X } from "lucide-react";
|
|||||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
import { useLongPress } from "../hooks/use-long-press.js";
|
import { useLongPress } from "../hooks/use-long-press.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
@@ -18,6 +20,7 @@ import { ConditionPicker } from "./condition-picker.js";
|
|||||||
import { ConditionTags } from "./condition-tags.js";
|
import { ConditionTags } from "./condition-tags.js";
|
||||||
import { D20Icon } from "./d20-icon.js";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
import { HpAdjustPopover } from "./hp-adjust-popover.js";
|
import { HpAdjustPopover } from "./hp-adjust-popover.js";
|
||||||
|
import { PersistentDamageTags } from "./persistent-damage-tags.js";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||||
@@ -31,7 +34,8 @@ interface Combatant {
|
|||||||
readonly currentHp?: number;
|
readonly currentHp?: number;
|
||||||
readonly tempHp?: number;
|
readonly tempHp?: number;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionId[];
|
readonly conditions?: readonly ConditionEntry[];
|
||||||
|
readonly persistentDamage?: readonly PersistentDamageEntry[];
|
||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
readonly color?: string;
|
readonly color?: string;
|
||||||
readonly icon?: string;
|
readonly icon?: string;
|
||||||
@@ -415,12 +419,14 @@ function InitiativeDisplay({
|
|||||||
function rowBorderClass(
|
function rowBorderClass(
|
||||||
isActive: boolean,
|
isActive: boolean,
|
||||||
isConcentrating: boolean | undefined,
|
isConcentrating: boolean | undefined,
|
||||||
|
isPf2e: boolean,
|
||||||
): string {
|
): string {
|
||||||
if (isActive && isConcentrating)
|
const showConcentration = isConcentrating && !isPf2e;
|
||||||
|
if (isActive && showConcentration)
|
||||||
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
|
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
|
||||||
if (isActive)
|
if (isActive)
|
||||||
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
|
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
|
||||||
if (isConcentrating)
|
if (showConcentration)
|
||||||
return "border border-l-2 border-transparent border-l-purple-400";
|
return "border border-l-2 border-transparent border-l-purple-400";
|
||||||
return "border border-l-2 border-transparent";
|
return "border border-l-2 border-transparent";
|
||||||
}
|
}
|
||||||
@@ -448,14 +454,26 @@ export function CombatantRow({
|
|||||||
setTempHp,
|
setTempHp,
|
||||||
setAc,
|
setAc,
|
||||||
toggleCondition,
|
toggleCondition,
|
||||||
|
setConditionValue,
|
||||||
|
decrementCondition,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
|
addPersistentDamage,
|
||||||
|
removePersistentDamage,
|
||||||
} = useEncounterContext();
|
} = useEncounterContext();
|
||||||
const { selectedCreatureId, showCreature, toggleCollapse } =
|
const {
|
||||||
useSidePanelContext();
|
selectedCreatureId,
|
||||||
|
selectedCombatantId,
|
||||||
|
showCreature,
|
||||||
|
toggleCollapse,
|
||||||
|
} = useSidePanelContext();
|
||||||
const { handleRollInitiative } = useInitiativeRollsContext();
|
const { handleRollInitiative } = useInitiativeRollsContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
const isPf2e = edition === "pf2e";
|
||||||
|
|
||||||
// Derive what was previously conditional props
|
// Derive what was previously conditional props
|
||||||
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
|
const isStatBlockOpen =
|
||||||
|
combatant.creatureId === selectedCreatureId &&
|
||||||
|
combatant.id === selectedCombatantId;
|
||||||
const { creatureId } = combatant;
|
const { creatureId } = combatant;
|
||||||
const hasStatBlock = !!creatureId;
|
const hasStatBlock = !!creatureId;
|
||||||
const onToggleStatBlock = hasStatBlock
|
const onToggleStatBlock = hasStatBlock
|
||||||
@@ -463,7 +481,7 @@ export function CombatantRow({
|
|||||||
if (isStatBlockOpen) {
|
if (isStatBlockOpen) {
|
||||||
toggleCollapse();
|
toggleCollapse();
|
||||||
} else {
|
} else {
|
||||||
showCreature(creatureId);
|
showCreature(creatureId, combatant.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -493,12 +511,16 @@ export function CombatantRow({
|
|||||||
const tempHpDropped =
|
const tempHpDropped =
|
||||||
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
||||||
|
|
||||||
if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
|
if (
|
||||||
|
(realHpDropped || tempHpDropped) &&
|
||||||
|
combatant.isConcentrating &&
|
||||||
|
!isPf2e
|
||||||
|
) {
|
||||||
setIsPulsing(true);
|
setIsPulsing(true);
|
||||||
clearTimeout(pulseTimerRef.current);
|
clearTimeout(pulseTimerRef.current);
|
||||||
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||||
}
|
}
|
||||||
}, [currentHp, combatant.tempHp, combatant.isConcentrating]);
|
}, [currentHp, combatant.tempHp, combatant.isConcentrating, isPf2e]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!combatant.isConcentrating) {
|
if (!combatant.isConcentrating) {
|
||||||
@@ -516,12 +538,20 @@ export function CombatantRow({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group rounded-lg pr-3 transition-colors",
|
"group rounded-lg pr-3 transition-colors",
|
||||||
rowBorderClass(isActive, combatant.isConcentrating),
|
rowBorderClass(isActive, combatant.isConcentrating, isPf2e),
|
||||||
isPulsing && "animate-concentration-pulse",
|
isPulsing && "animate-concentration-pulse",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-[2rem_3rem_auto_1fr_auto_2rem] items-center gap-1.5 py-3 sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] sm:gap-3 sm:py-2">
|
<div
|
||||||
{/* Concentration */}
|
className={cn(
|
||||||
|
"grid items-center gap-1.5 py-3 sm:gap-3 sm:py-2",
|
||||||
|
isPf2e
|
||||||
|
? "grid-cols-[3rem_auto_1fr_auto_2rem] pl-3 sm:grid-cols-[3.5rem_auto_1fr_auto_2rem]"
|
||||||
|
: "grid-cols-[2rem_3rem_auto_1fr_auto_2rem] sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Concentration — hidden in PF2e mode */}
|
||||||
|
{!isPf2e && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleConcentration(id)}
|
onClick={() => toggleConcentration(id)}
|
||||||
@@ -534,6 +564,7 @@ export function CombatantRow({
|
|||||||
>
|
>
|
||||||
<Brain size={16} />
|
<Brain size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Initiative */}
|
{/* Initiative */}
|
||||||
<div className="rounded-md bg-muted/30 px-1">
|
<div className="rounded-md bg-muted/30 px-1">
|
||||||
@@ -585,14 +616,28 @@ export function CombatantRow({
|
|||||||
<ConditionTags
|
<ConditionTags
|
||||||
conditions={combatant.conditions}
|
conditions={combatant.conditions}
|
||||||
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
|
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
||||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isPf2e && (
|
||||||
|
<PersistentDamageTags
|
||||||
|
entries={combatant.persistentDamage}
|
||||||
|
onRemove={(damageType) => removePersistentDamage(id, damageType)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!!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) =>
|
||||||
|
setConditionValue(id, conditionId, value)
|
||||||
|
}
|
||||||
|
onAddPersistentDamage={(damageType, formula) =>
|
||||||
|
addPersistentDamage(id, damageType, formula)
|
||||||
|
}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
|
type ConditionEntry,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
getConditionsForEdition,
|
getConditionsForEdition,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PersistentDamageType,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useLayoutEffect, useRef, useState } from "react";
|
import { Check, Flame, Minus, Plus } from "lucide-react";
|
||||||
|
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { 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";
|
||||||
@@ -12,19 +16,29 @@ import {
|
|||||||
CONDITION_COLOR_CLASSES,
|
CONDITION_COLOR_CLASSES,
|
||||||
CONDITION_ICON_MAP,
|
CONDITION_ICON_MAP,
|
||||||
} from "./condition-styles.js";
|
} from "./condition-styles.js";
|
||||||
|
import { PersistentDamagePicker } from "./persistent-damage-picker.js";
|
||||||
import { Tooltip } from "./ui/tooltip.js";
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
interface ConditionPickerProps {
|
interface ConditionPickerProps {
|
||||||
anchorRef: React.RefObject<HTMLElement | null>;
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
activeConditions: readonly ConditionId[] | undefined;
|
activeConditions: readonly ConditionEntry[] | undefined;
|
||||||
|
activePersistentDamage?: readonly PersistentDamageEntry[];
|
||||||
onToggle: (conditionId: ConditionId) => void;
|
onToggle: (conditionId: ConditionId) => 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,
|
||||||
|
onAddPersistentDamage,
|
||||||
onClose,
|
onClose,
|
||||||
}: Readonly<ConditionPickerProps>) {
|
}: Readonly<ConditionPickerProps>) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -34,6 +48,12 @@ export function ConditionPicker({
|
|||||||
maxHeight: number;
|
maxHeight: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<{
|
||||||
|
id: ConditionId;
|
||||||
|
value: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [showPersistentDamage, setShowPersistentDamage] = useState(false);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const anchor = anchorRef.current;
|
const anchor = anchorRef.current;
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
@@ -59,7 +79,54 @@ export function ConditionPicker({
|
|||||||
|
|
||||||
const { edition } = useRulesEditionContext();
|
const { edition } = useRulesEditionContext();
|
||||||
const conditions = getConditionsForEdition(edition);
|
const conditions = getConditionsForEdition(edition);
|
||||||
const active = new Set(activeConditions ?? []);
|
const activeMap = new Map(
|
||||||
|
(activeConditions ?? []).map((e) => [e.id, e.value]),
|
||||||
|
);
|
||||||
|
const showPersistentDamageEntry =
|
||||||
|
edition === "pf2e" && !!onAddPersistentDamage;
|
||||||
|
const persistentDamageInsertIndex = showPersistentDamageEntry
|
||||||
|
? conditions.findIndex(
|
||||||
|
(d) => d.label.localeCompare("Persistent Damage") > 0,
|
||||||
|
)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
const persistentDamageEntry = showPersistentDamageEntry ? (
|
||||||
|
<React.Fragment key="persistent-damage">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||||
|
showPersistentDamage && "bg-card/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex flex-1 items-center gap-2"
|
||||||
|
onClick={() => setShowPersistentDamage((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<Flame
|
||||||
|
size={14}
|
||||||
|
className={
|
||||||
|
showPersistentDamage ? "text-orange-400" : "text-muted-foreground"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
showPersistentDamage ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Persistent Damage
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!!showPersistentDamage && (
|
||||||
|
<PersistentDamagePicker
|
||||||
|
activeEntries={activePersistentDamage}
|
||||||
|
onAdd={onAddPersistentDamage}
|
||||||
|
onClose={() => setShowPersistentDamage(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -71,41 +138,138 @@ export function ConditionPicker({
|
|||||||
: { visibility: "hidden" as const }
|
: { visibility: "hidden" as const }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{conditions.map((def) => {
|
{conditions.map((def, index) => {
|
||||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const isActive = active.has(def.id);
|
const isActive = activeMap.has(def.id);
|
||||||
|
const activeValue = activeMap.get(def.id);
|
||||||
|
const isEditing = editing?.id === def.id;
|
||||||
const colorClass =
|
const colorClass =
|
||||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (def.valued && edition === "pf2e") {
|
||||||
|
const current = activeMap.get(def.id);
|
||||||
|
setEditing({
|
||||||
|
id: def.id,
|
||||||
|
value: current ?? 1,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onToggle(def.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||||
|
(isActive || isEditing) && "bg-card/50",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className="flex flex-1 items-center gap-2"
|
||||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
onClick={handleClick}
|
||||||
isActive && "bg-card/50",
|
|
||||||
)}
|
|
||||||
onClick={() => onToggle(def.id)}
|
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
size={14}
|
size={14}
|
||||||
className={isActive ? colorClass : "text-muted-foreground"}
|
className={
|
||||||
|
isActive || isEditing
|
||||||
|
? colorClass
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
isActive ? "text-foreground" : "text-muted-foreground"
|
isActive || isEditing
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{def.label}
|
{def.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{isActive && def.valued && edition === "pf2e" && !isEditing && (
|
||||||
|
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
||||||
|
{activeValue}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isEditing && (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (editing.value > 1) {
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
value: editing.value - 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Minus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
||||||
|
{editing.value}
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const atMax =
|
||||||
|
def.maxValue !== undefined &&
|
||||||
|
editing.value >= def.maxValue;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"rounded p-0.5",
|
||||||
|
atMax
|
||||||
|
? "cursor-not-allowed text-muted-foreground opacity-50"
|
||||||
|
: "text-foreground hover:bg-accent/40",
|
||||||
|
)}
|
||||||
|
disabled={atMax}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!atMax) {
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
value: editing.value + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSetValue(editing.id, editing.value);
|
||||||
|
setEditing(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{persistentDamageInsertIndex === -1 && persistentDamageEntry}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,42 +1,80 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
Anchor,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Ban,
|
Ban,
|
||||||
BatteryLow,
|
BatteryLow,
|
||||||
|
BrainCog,
|
||||||
|
CircleHelp,
|
||||||
|
CloudFog,
|
||||||
|
Drama,
|
||||||
Droplet,
|
Droplet,
|
||||||
|
Droplets,
|
||||||
EarOff,
|
EarOff,
|
||||||
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
Flame,
|
||||||
|
FlaskConical,
|
||||||
|
Footprints,
|
||||||
Gem,
|
Gem,
|
||||||
Ghost,
|
Ghost,
|
||||||
Hand,
|
Hand,
|
||||||
Heart,
|
Heart,
|
||||||
|
HeartCrack,
|
||||||
|
HeartPulse,
|
||||||
Link,
|
Link,
|
||||||
Moon,
|
Moon,
|
||||||
|
PersonStanding,
|
||||||
ShieldMinus,
|
ShieldMinus,
|
||||||
|
ShieldOff,
|
||||||
Siren,
|
Siren,
|
||||||
|
Skull,
|
||||||
Snail,
|
Snail,
|
||||||
|
Snowflake,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Sun,
|
||||||
|
TrendingDown,
|
||||||
|
Zap,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||||
EyeOff,
|
Anchor,
|
||||||
Heart,
|
|
||||||
EarOff,
|
|
||||||
BatteryLow,
|
|
||||||
Siren,
|
|
||||||
Hand,
|
|
||||||
Ban,
|
|
||||||
Ghost,
|
|
||||||
ZapOff,
|
|
||||||
Gem,
|
|
||||||
Droplet,
|
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
|
Ban,
|
||||||
|
BatteryLow,
|
||||||
|
BrainCog,
|
||||||
|
CircleHelp,
|
||||||
|
CloudFog,
|
||||||
|
Drama,
|
||||||
|
Droplet,
|
||||||
|
Droplets,
|
||||||
|
EarOff,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Flame,
|
||||||
|
FlaskConical,
|
||||||
|
Footprints,
|
||||||
|
Gem,
|
||||||
|
Ghost,
|
||||||
|
Hand,
|
||||||
|
Heart,
|
||||||
|
HeartCrack,
|
||||||
|
HeartPulse,
|
||||||
Link,
|
Link,
|
||||||
ShieldMinus,
|
|
||||||
Snail,
|
|
||||||
Sparkles,
|
|
||||||
Moon,
|
Moon,
|
||||||
|
PersonStanding,
|
||||||
|
ShieldMinus,
|
||||||
|
ShieldOff,
|
||||||
|
Siren,
|
||||||
|
Skull,
|
||||||
|
Snail,
|
||||||
|
Snowflake,
|
||||||
|
Sparkles,
|
||||||
|
Sun,
|
||||||
|
TrendingDown,
|
||||||
|
Zap,
|
||||||
|
ZapOff,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||||
@@ -49,6 +87,8 @@ export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
|||||||
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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
CONDITION_DEFINITIONS,
|
CONDITION_DEFINITIONS,
|
||||||
|
type ConditionEntry,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
@@ -13,44 +14,57 @@ import {
|
|||||||
import { Tooltip } from "./ui/tooltip.js";
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
interface ConditionTagsProps {
|
interface ConditionTagsProps {
|
||||||
conditions: readonly ConditionId[] | undefined;
|
conditions: readonly ConditionEntry[] | undefined;
|
||||||
onRemove: (conditionId: ConditionId) => void;
|
onRemove: (conditionId: ConditionId) => void;
|
||||||
|
onDecrement: (conditionId: ConditionId) => void;
|
||||||
onOpenPicker: () => void;
|
onOpenPicker: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConditionTags({
|
export function ConditionTags({
|
||||||
conditions,
|
conditions,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onDecrement,
|
||||||
onOpenPicker,
|
onOpenPicker,
|
||||||
}: Readonly<ConditionTagsProps>) {
|
}: Readonly<ConditionTagsProps>) {
|
||||||
const { edition } = useRulesEditionContext();
|
const { edition } = useRulesEditionContext();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-0.5">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
{conditions?.map((condId) => {
|
{conditions?.map((entry) => {
|
||||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
|
const def = CONDITION_DEFINITIONS.find((d) => d.id === entry.id);
|
||||||
if (!def) return null;
|
if (!def) return null;
|
||||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const colorClass =
|
const colorClass =
|
||||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
|
const tooltipLabel =
|
||||||
|
entry.value === undefined ? def.label : `${def.label} ${entry.value}`;
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={condId}
|
key={entry.id}
|
||||||
content={`${def.label}:\n${getConditionDescription(def, edition)}`}
|
content={`${tooltipLabel}:\n${getConditionDescription(def, edition)}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Remove ${def.label}`}
|
aria-label={`Remove ${def.label}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
"inline-flex items-center gap-0.5 rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||||
colorClass,
|
colorClass,
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove(condId);
|
if (entry.value === undefined) {
|
||||||
|
onRemove(entry.id);
|
||||||
|
} else {
|
||||||
|
onDecrement(entry.id);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon size={14} />
|
<Icon size={14} />
|
||||||
|
{entry.value !== undefined && (
|
||||||
|
<span className="font-medium text-xs leading-none">
|
||||||
|
{entry.value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
141
apps/web/src/components/detail-popover.tsx
Normal file
141
apps/web/src/components/detail-popover.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
|
import { useSwipeToDismissDown } from "../hooks/use-swipe-to-dismiss.js";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
|
||||||
|
interface DetailPopoverProps {
|
||||||
|
readonly anchorRect: DOMRect;
|
||||||
|
readonly onClose: () => void;
|
||||||
|
readonly ariaLabel: string;
|
||||||
|
readonly children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DesktopPanel({
|
||||||
|
anchorRect,
|
||||||
|
onClose,
|
||||||
|
ariaLabel,
|
||||||
|
children,
|
||||||
|
}: Readonly<DetailPopoverProps>) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const popover = el.getBoundingClientRect();
|
||||||
|
const vw = document.documentElement.clientWidth;
|
||||||
|
const vh = document.documentElement.clientHeight;
|
||||||
|
// Prefer placement to the LEFT of the anchor (panel is on the right edge)
|
||||||
|
let left = anchorRect.left - popover.width - 8;
|
||||||
|
if (left < 8) {
|
||||||
|
left = anchorRect.right + 8;
|
||||||
|
}
|
||||||
|
if (left + popover.width > vw - 8) {
|
||||||
|
left = vw - popover.width - 8;
|
||||||
|
}
|
||||||
|
let top = anchorRect.top;
|
||||||
|
if (top + popover.height > vh - 8) {
|
||||||
|
top = vh - popover.height - 8;
|
||||||
|
}
|
||||||
|
if (top < 8) top = 8;
|
||||||
|
setPos({ top, left });
|
||||||
|
}, [anchorRect]);
|
||||||
|
|
||||||
|
useClickOutside(ref, onClose);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="card-glow fixed z-50 max-h-[calc(100vh-16px)] w-80 max-w-[calc(100vw-16px)] overflow-y-auto rounded-lg border border-border bg-card p-4 shadow-lg"
|
||||||
|
style={pos ? { top: pos.top, left: pos.left } : { visibility: "hidden" }}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileSheet({
|
||||||
|
onClose,
|
||||||
|
ariaLabel,
|
||||||
|
children,
|
||||||
|
}: Readonly<Omit<DetailPopoverProps, "anchorRect">>) {
|
||||||
|
const { offsetY, isSwiping, handlers } = useSwipeToDismissDown(onClose);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handler);
|
||||||
|
return () => document.removeEventListener("keydown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fade-in absolute inset-0 animate-in bg-black/50"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close details"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"panel-glow absolute right-0 bottom-0 left-0 max-h-[80vh] rounded-t-2xl border-border border-t bg-card",
|
||||||
|
!isSwiping && "animate-slide-in-bottom",
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
isSwiping ? { transform: `translateY(${offsetY}px)` } : undefined
|
||||||
|
}
|
||||||
|
{...handlers}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center pt-2 pb-1">
|
||||||
|
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[calc(80vh-24px)] overflow-y-auto p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailPopover({
|
||||||
|
anchorRect,
|
||||||
|
onClose,
|
||||||
|
ariaLabel,
|
||||||
|
children,
|
||||||
|
}: Readonly<DetailPopoverProps>) {
|
||||||
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
|
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Portal to document.body to escape any CSS transforms on ancestors
|
||||||
|
// (the side panel uses translate-x for collapse animation, which would
|
||||||
|
// otherwise become the containing block for fixed-positioned children).
|
||||||
|
const content = isDesktop ? (
|
||||||
|
<DesktopPanel
|
||||||
|
anchorRect={anchorRect}
|
||||||
|
onClose={onClose}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DesktopPanel>
|
||||||
|
) : (
|
||||||
|
<MobileSheet onClose={onClose} ariaLabel={ariaLabel}>
|
||||||
|
{children}
|
||||||
|
</MobileSheet>
|
||||||
|
);
|
||||||
|
return createPortal(content, document.body);
|
||||||
|
}
|
||||||
@@ -1,37 +1,96 @@
|
|||||||
import type { DifficultyTier } from "@initiative/domain";
|
import type { DifficultyTier, RulesEdition } from "@initiative/domain";
|
||||||
|
import { ArrowLeftRight } from "lucide-react";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-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";
|
||||||
import {
|
import {
|
||||||
type BreakdownCombatant,
|
type BreakdownCombatant,
|
||||||
useDifficultyBreakdown,
|
useDifficultyBreakdown,
|
||||||
} from "../hooks/use-difficulty-breakdown.js";
|
} from "../hooks/use-difficulty-breakdown.js";
|
||||||
import { CrPicker } from "./cr-picker.js";
|
import { CrPicker } from "./cr-picker.js";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
const TIER_LABELS: Record<DifficultyTier, { label: string; color: string }> = {
|
const TIER_LABEL_MAP: Partial<
|
||||||
trivial: { label: "Trivial", color: "text-muted-foreground" },
|
Record<RulesEdition, Record<DifficultyTier, { label: string; color: string }>>
|
||||||
low: { label: "Low", color: "text-green-500" },
|
> = {
|
||||||
moderate: { label: "Moderate", color: "text-yellow-500" },
|
"5.5e": {
|
||||||
high: { label: "High", color: "text-red-500" },
|
0: { label: "Trivial", color: "text-muted-foreground" },
|
||||||
|
1: { label: "Low", color: "text-green-500" },
|
||||||
|
2: { label: "Moderate", color: "text-yellow-500" },
|
||||||
|
3: { label: "High", color: "text-red-500" },
|
||||||
|
},
|
||||||
|
"5e": {
|
||||||
|
0: { label: "Easy", color: "text-muted-foreground" },
|
||||||
|
1: { label: "Medium", color: "text-green-500" },
|
||||||
|
2: { label: "Hard", color: "text-yellow-500" },
|
||||||
|
3: { label: "Deadly", color: "text-red-500" },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Short labels for threshold display where horizontal space is limited. */
|
||||||
|
const SHORT_LABELS: Readonly<Record<string, string>> = {
|
||||||
|
Moderate: "Mod",
|
||||||
|
Medium: "Med",
|
||||||
|
};
|
||||||
|
|
||||||
|
function shortLabel(label: string): string {
|
||||||
|
return SHORT_LABELS[label] ?? label;
|
||||||
|
}
|
||||||
|
|
||||||
function formatXp(xp: number): string {
|
function formatXp(xp: number): string {
|
||||||
return xp.toLocaleString();
|
return xp.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function CombatantRow({ entry }: { entry: BreakdownCombatant }) {
|
function PcRow({ entry }: { entry: BreakdownCombatant }) {
|
||||||
const { setCr } = useEncounterContext();
|
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>
|
||||||
|
<span />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{entry.level === undefined ? "\u2014" : `Lv ${entry.level}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-right tabular-nums">{"\u2014"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const nameLabel = entry.source
|
function NpcRow({
|
||||||
? `${entry.combatant.name} (${entry.source})`
|
entry,
|
||||||
: entry.combatant.name;
|
onToggleSide,
|
||||||
|
}: {
|
||||||
|
entry: BreakdownCombatant;
|
||||||
|
onToggleSide: () => void;
|
||||||
|
}) {
|
||||||
|
const { setCr } = useEncounterContext();
|
||||||
|
const isParty = entry.side === "party";
|
||||||
|
const targetSide = isParty ? "enemy" : "party";
|
||||||
|
|
||||||
|
let xpDisplay: string;
|
||||||
|
if (entry.xp == null) {
|
||||||
|
xpDisplay = "\u2014";
|
||||||
|
} else if (isParty && entry.cr) {
|
||||||
|
xpDisplay = `\u2212${formatXp(entry.xp)}`;
|
||||||
|
} else {
|
||||||
|
xpDisplay = formatXp(entry.xp);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2 text-xs">
|
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
|
||||||
<span className="min-w-0 truncate" title={nameLabel}>
|
<span className="min-w-0 truncate" title={entry.combatant.name}>
|
||||||
{nameLabel}
|
{entry.combatant.name}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<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>
|
||||||
{entry.editable ? (
|
{entry.editable ? (
|
||||||
<CrPicker
|
<CrPicker
|
||||||
value={entry.cr}
|
value={entry.cr}
|
||||||
@@ -39,13 +98,11 @@ function CombatantRow({ entry }: { entry: BreakdownCombatant }) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{entry.cr ? `CR ${entry.cr}` : "—"}
|
{entry.cr ? `CR ${entry.cr}` : "\u2014"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="w-12 text-right tabular-nums">
|
|
||||||
{entry.xp == null ? "—" : formatXp(entry.xp)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="text-right tabular-nums">{xpDisplay}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -53,16 +110,28 @@ function CombatantRow({ entry }: { entry: BreakdownCombatant }) {
|
|||||||
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);
|
||||||
|
const { setSide } = useEncounterContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
|
||||||
const breakdown = useDifficultyBreakdown();
|
const breakdown = useDifficultyBreakdown();
|
||||||
if (!breakdown) return null;
|
if (!breakdown) return null;
|
||||||
|
|
||||||
const tierConfig = TIER_LABELS[breakdown.tier];
|
const tierLabels = TIER_LABEL_MAP[edition];
|
||||||
|
if (!tierLabels) return null;
|
||||||
|
const tierConfig = tierLabels[breakdown.tier];
|
||||||
|
|
||||||
|
const handleToggle = (entry: BreakdownCombatant) => {
|
||||||
|
const newSide = entry.side === "party" ? "enemy" : "party";
|
||||||
|
setSide(entry.combatant.id, newSide);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPC = (entry: BreakdownCombatant) =>
|
||||||
|
entry.combatant.playerCharacterId != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="absolute top-full right-0 z-50 mt-1 w-72 rounded-lg border border-border bg-card p-3 shadow-lg"
|
className="absolute top-full right-0 z-50 mt-1 w-80 rounded-lg border border-border bg-card p-3 shadow-lg max-sm:fixed max-sm:top-12 max-sm:right-3 max-sm:left-3 max-sm:w-auto"
|
||||||
>
|
>
|
||||||
<div className="mb-2 font-medium text-sm">
|
<div className="mb-2 font-medium text-sm">
|
||||||
Encounter Difficulty:{" "}
|
Encounter Difficulty:{" "}
|
||||||
@@ -75,35 +144,86 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
|||||||
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 text-xs">
|
<div className="flex gap-3 text-xs">
|
||||||
<span>
|
{breakdown.thresholds.map((t) => (
|
||||||
Low: <strong>{formatXp(breakdown.partyBudget.low)}</strong>
|
<span key={t.label}>
|
||||||
</span>
|
{shortLabel(t.label)}: <strong>{formatXp(t.value)}</strong>
|
||||||
<span>
|
|
||||||
Mod: <strong>{formatXp(breakdown.partyBudget.moderate)}</strong>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
High: <strong>{formatXp(breakdown.partyBudget.high)}</strong>
|
|
||||||
</span>
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-border border-t pt-2 pb-2 text-muted-foreground text-xs italic">
|
||||||
|
Allied NPC XP is subtracted from encounter difficulty
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-border border-t pt-2">
|
<div className="border-border border-t pt-2">
|
||||||
<div className="mb-1 flex justify-between text-muted-foreground text-xs">
|
<div className="mb-1 flex justify-between text-muted-foreground text-xs">
|
||||||
<span>Monsters</span>
|
<span>Party</span>
|
||||||
<span>XP</span>
|
<span>XP</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="grid grid-cols-[1fr_auto_auto_3.5rem] gap-x-2 gap-y-1">
|
||||||
{breakdown.combatants.map((entry) => (
|
{breakdown.partyCombatants.map((entry) =>
|
||||||
<CombatantRow key={entry.combatant.id} entry={entry} />
|
isPC(entry) ? (
|
||||||
))}
|
<PcRow key={entry.combatant.id} entry={entry} />
|
||||||
|
) : (
|
||||||
|
<NpcRow
|
||||||
|
key={entry.combatant.id}
|
||||||
|
entry={entry}
|
||||||
|
onToggleSide={() => handleToggle(entry)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 border-border border-t pt-2">
|
||||||
|
<div className="mb-1 flex justify-between text-muted-foreground text-xs">
|
||||||
|
<span>Enemy</span>
|
||||||
|
<span>XP</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto_3.5rem] gap-x-2 gap-y-1">
|
||||||
|
{breakdown.enemyCombatants.map((entry) =>
|
||||||
|
isPC(entry) ? (
|
||||||
|
<PcRow key={entry.combatant.id} entry={entry} />
|
||||||
|
) : (
|
||||||
|
<NpcRow
|
||||||
|
key={entry.combatant.id}
|
||||||
|
entry={entry}
|
||||||
|
onToggleSide={() => handleToggle(entry)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{breakdown.encounterMultiplier !== undefined &&
|
||||||
|
breakdown.adjustedXp !== undefined ? (
|
||||||
|
<div className="mt-2 border-border border-t pt-2">
|
||||||
|
<div className="flex justify-between font-medium text-xs">
|
||||||
|
<span>Monster XP</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatXp(breakdown.totalMonsterXp)}{" "}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
×{breakdown.encounterMultiplier}
|
||||||
|
</span>{" "}
|
||||||
|
= {formatXp(breakdown.adjustedXp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{breakdown.partySizeAdjusted === true ? (
|
||||||
|
<div className="mt-0.5 text-muted-foreground text-xs italic">
|
||||||
|
Adjusted for {breakdown.pcCount}{" "}
|
||||||
|
{breakdown.pcCount === 1 ? "PC" : "PCs"}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</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>Total Monster XP</span>
|
<span>Net Monster XP</span>
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{formatXp(breakdown.totalMonsterXp)}
|
{formatXp(breakdown.totalMonsterXp)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,44 @@
|
|||||||
import type { DifficultyResult, DifficultyTier } from "@initiative/domain";
|
import type { DifficultyResult, DifficultyTier } from "@initiative/domain";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
|
|
||||||
const TIER_CONFIG: Record<
|
export const TIER_LABELS_5_5E: Record<DifficultyTier, string> = {
|
||||||
|
0: "Trivial",
|
||||||
|
1: "Low",
|
||||||
|
2: "Moderate",
|
||||||
|
3: "High",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||||
|
0: "Easy",
|
||||||
|
1: "Medium",
|
||||||
|
2: "Hard",
|
||||||
|
3: "Deadly",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIER_COLORS: Record<
|
||||||
DifficultyTier,
|
DifficultyTier,
|
||||||
{ filledBars: number; color: string; label: string }
|
{ filledBars: number; color: string }
|
||||||
> = {
|
> = {
|
||||||
trivial: { filledBars: 0, color: "", label: "Trivial" },
|
0: { filledBars: 0, color: "" },
|
||||||
low: { filledBars: 1, color: "bg-green-500", label: "Low" },
|
1: { filledBars: 1, color: "bg-green-500" },
|
||||||
moderate: { filledBars: 2, color: "bg-yellow-500", label: "Moderate" },
|
2: { filledBars: 2, color: "bg-yellow-500" },
|
||||||
high: { filledBars: 3, color: "bg-red-500", label: "High" },
|
3: { filledBars: 3, color: "bg-red-500" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
||||||
|
|
||||||
export function DifficultyIndicator({
|
export function DifficultyIndicator({
|
||||||
result,
|
result,
|
||||||
|
labels,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
result: DifficultyResult;
|
result: DifficultyResult;
|
||||||
|
labels: Record<DifficultyTier, string>;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const config = TIER_CONFIG[result.tier];
|
const config = TIER_COLORS[result.tier];
|
||||||
const tooltip = `${config.label} encounter difficulty`;
|
const label = labels[result.tier];
|
||||||
|
const tooltip = `${label} encounter difficulty`;
|
||||||
|
|
||||||
const Element = onClick ? "button" : "div";
|
const Element = onClick ? "button" : "div";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import type { Creature } from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
type Creature,
|
|
||||||
calculateInitiative,
|
calculateInitiative,
|
||||||
formatInitiativeModifier,
|
formatInitiativeModifier,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
PropertyLine,
|
||||||
|
SectionDivider,
|
||||||
|
TraitEntry,
|
||||||
|
TraitSection,
|
||||||
|
} from "./stat-block-parts.js";
|
||||||
|
|
||||||
interface StatBlockProps {
|
interface DndStatBlockProps {
|
||||||
creature: Creature;
|
creature: Creature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,53 +19,7 @@ function abilityMod(score: number): string {
|
|||||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PropertyLine({
|
export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: Readonly<{
|
|
||||||
label: string;
|
|
||||||
value: string | undefined;
|
|
||||||
}>) {
|
|
||||||
if (!value) return null;
|
|
||||||
return (
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="font-semibold">{label}</span> {value}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionDivider() {
|
|
||||||
return (
|
|
||||||
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TraitSection({
|
|
||||||
entries,
|
|
||||||
heading,
|
|
||||||
}: Readonly<{
|
|
||||||
entries: readonly { name: string; text: string }[] | undefined;
|
|
||||||
heading?: string;
|
|
||||||
}>) {
|
|
||||||
if (!entries || entries.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SectionDivider />
|
|
||||||
{heading ? (
|
|
||||||
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
|
|
||||||
) : null}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{entries.map((e) => (
|
|
||||||
<div key={e.name} className="text-sm">
|
|
||||||
<span className="font-semibold italic">{e.name}.</span> {e.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|
||||||
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 },
|
||||||
@@ -174,7 +134,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
{sc.atWill && sc.atWill.length > 0 && (
|
{sc.atWill && sc.atWill.length > 0 && (
|
||||||
<div className="pl-2">
|
<div className="pl-2">
|
||||||
<span className="font-semibold">At Will:</span>{" "}
|
<span className="font-semibold">At Will:</span>{" "}
|
||||||
{sc.atWill.join(", ")}
|
{sc.atWill.map((s) => s.name).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sc.daily?.map((d) => (
|
{sc.daily?.map((d) => (
|
||||||
@@ -183,7 +143,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
{d.uses}/day
|
{d.uses}/day
|
||||||
{d.each ? " each" : ""}:
|
{d.each ? " each" : ""}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
{d.spells.join(", ")}
|
{d.spells.map((s) => s.name).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{sc.restLong?.map((d) => (
|
{sc.restLong?.map((d) => (
|
||||||
@@ -195,7 +155,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
{d.uses}/long rest
|
{d.uses}/long rest
|
||||||
{d.each ? " each" : ""}:
|
{d.each ? " each" : ""}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
{d.spells.join(", ")}
|
{d.spells.map((s) => s.name).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -219,9 +179,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
|||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{creature.legendaryActions.entries.map((a) => (
|
{creature.legendaryActions.entries.map((a) => (
|
||||||
<div key={a.name} className="text-sm">
|
<TraitEntry key={a.name} trait={a} />
|
||||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
72
apps/web/src/components/equipment-detail-popover.tsx
Normal file
72
apps/web/src/components/equipment-detail-popover.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { EquipmentItem } from "@initiative/domain";
|
||||||
|
import { DetailPopover } from "./detail-popover.js";
|
||||||
|
import { RichDescription } from "./rich-description.js";
|
||||||
|
|
||||||
|
interface EquipmentDetailPopoverProps {
|
||||||
|
readonly item: EquipmentItem;
|
||||||
|
readonly anchorRect: DOMRect;
|
||||||
|
readonly onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EquipmentDetailContent({ item }: Readonly<{ item: EquipmentItem }>) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<h3 className="font-bold text-lg text-stat-heading">{item.name}</h3>
|
||||||
|
{item.traits && item.traits.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{item.traits.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="rounded border border-border bg-card px-1.5 py-0.5 text-foreground text-xs"
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-0.5 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Level</span> {item.level}
|
||||||
|
</div>
|
||||||
|
{item.category ? (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Category</span>{" "}
|
||||||
|
{item.category.charAt(0).toUpperCase() + item.category.slice(1)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.spellName ? (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Spell</span> {item.spellName}
|
||||||
|
{item.spellRank === undefined ? "" : ` (Rank ${item.spellRank})`}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{item.description ? (
|
||||||
|
<RichDescription
|
||||||
|
text={item.description}
|
||||||
|
className="whitespace-pre-line text-foreground"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground italic">
|
||||||
|
No description available.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquipmentDetailPopover({
|
||||||
|
item,
|
||||||
|
anchorRect,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<EquipmentDetailPopoverProps>) {
|
||||||
|
return (
|
||||||
|
<DetailPopover
|
||||||
|
anchorRect={anchorRect}
|
||||||
|
onClose={onClose}
|
||||||
|
ariaLabel={`Equipment details: ${item.name}`}
|
||||||
|
>
|
||||||
|
<EquipmentDetailContent item={item} />
|
||||||
|
</DetailPopover>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/web/src/components/persistent-damage-picker.tsx
Normal file
97
apps/web/src/components/persistent-damage-picker.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
PERSISTENT_DAMAGE_DEFINITIONS,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PersistentDamageType,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface PersistentDamagePickerProps {
|
||||||
|
activeEntries: readonly PersistentDamageEntry[] | undefined;
|
||||||
|
onAdd: (damageType: PersistentDamageType, formula: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PersistentDamagePicker({
|
||||||
|
activeEntries,
|
||||||
|
onAdd,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<PersistentDamagePickerProps>) {
|
||||||
|
const [selectedType, setSelectedType] = useState<PersistentDamageType>(
|
||||||
|
PERSISTENT_DAMAGE_DEFINITIONS[0].type,
|
||||||
|
);
|
||||||
|
const activeFormula =
|
||||||
|
activeEntries?.find((e) => e.type === selectedType)?.formula ?? "";
|
||||||
|
const [formula, setFormula] = useState(activeFormula);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const existing = activeEntries?.find(
|
||||||
|
(e) => e.type === selectedType,
|
||||||
|
)?.formula;
|
||||||
|
setFormula(existing ?? "");
|
||||||
|
}, [selectedType, activeEntries]);
|
||||||
|
|
||||||
|
const canSubmit = formula.trim().length > 0;
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (canSubmit) {
|
||||||
|
onAdd(selectedType, formula);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEscape(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 py-1 pr-2 pl-6">
|
||||||
|
<select
|
||||||
|
value={selectedType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedType(e.target.value as PersistentDamageType)
|
||||||
|
}
|
||||||
|
onKeyDown={handleEscape}
|
||||||
|
className="h-7 rounded border border-border bg-background px-1 text-foreground text-xs"
|
||||||
|
>
|
||||||
|
{PERSISTENT_DAMAGE_DEFINITIONS.map((def) => (
|
||||||
|
<option key={def.type} value={def.type}>
|
||||||
|
{def.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={formula}
|
||||||
|
placeholder="2d6"
|
||||||
|
className="h-7 w-16 rounded border border-border bg-background px-1.5 text-foreground text-xs"
|
||||||
|
onChange={(e) => setFormula(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
handleEscape(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="rounded p-0.5 text-foreground hover:bg-accent/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
aria-label="Add persistent damage"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/web/src/components/persistent-damage-tags.tsx
Normal file
63
apps/web/src/components/persistent-damage-tags.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
PERSISTENT_DAMAGE_DEFINITIONS,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PersistentDamageType,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
import {
|
||||||
|
CONDITION_COLOR_CLASSES,
|
||||||
|
CONDITION_ICON_MAP,
|
||||||
|
} from "./condition-styles.js";
|
||||||
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
|
interface PersistentDamageTagsProps {
|
||||||
|
entries: readonly PersistentDamageEntry[] | undefined;
|
||||||
|
onRemove: (damageType: PersistentDamageType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PersistentDamageTags({
|
||||||
|
entries,
|
||||||
|
onRemove,
|
||||||
|
}: Readonly<PersistentDamageTagsProps>) {
|
||||||
|
if (!entries || entries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const def = PERSISTENT_DAMAGE_DEFINITIONS.find(
|
||||||
|
(d) => d.type === entry.type,
|
||||||
|
);
|
||||||
|
if (!def) return null;
|
||||||
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||||
|
if (!Icon) return null;
|
||||||
|
const colorClass =
|
||||||
|
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={entry.type}
|
||||||
|
content={`Persistent ${def.label} ${entry.formula}\nTake damage at end of turn. DC 15 flat check to end.`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove persistent ${def.label} damage`}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-0.5 rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||||
|
colorClass,
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(entry.type);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
<span className="font-medium text-xs leading-none">
|
||||||
|
{entry.formula}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
391
apps/web/src/components/pf2e-stat-block.tsx
Normal file
391
apps/web/src/components/pf2e-stat-block.tsx
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import type {
|
||||||
|
CombatantId,
|
||||||
|
EquipmentItem,
|
||||||
|
Pf2eCreature,
|
||||||
|
SpellReference,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { formatInitiativeModifier, recallKnowledge } from "@initiative/domain";
|
||||||
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
import { EquipmentDetailPopover } from "./equipment-detail-popover.js";
|
||||||
|
import { SpellDetailPopover } from "./spell-detail-popover.js";
|
||||||
|
import {
|
||||||
|
PropertyLine,
|
||||||
|
SectionDivider,
|
||||||
|
TraitSection,
|
||||||
|
} from "./stat-block-parts.js";
|
||||||
|
|
||||||
|
interface Pf2eStatBlockProps {
|
||||||
|
creature: Pf2eCreature;
|
||||||
|
adjustment?: "weak" | "elite";
|
||||||
|
combatantId?: CombatantId;
|
||||||
|
baseCreature?: Pf2eCreature;
|
||||||
|
onSetAdjustment?: (
|
||||||
|
id: CombatantId,
|
||||||
|
adj: "weak" | "elite" | undefined,
|
||||||
|
base: Pf2eCreature,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALIGNMENTS = new Set([
|
||||||
|
"lg",
|
||||||
|
"ng",
|
||||||
|
"cg",
|
||||||
|
"ln",
|
||||||
|
"n",
|
||||||
|
"cn",
|
||||||
|
"le",
|
||||||
|
"ne",
|
||||||
|
"ce",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayTraits(traits: readonly string[]): string[] {
|
||||||
|
return traits.filter((t) => !ALIGNMENTS.has(t)).map(capitalize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMod(mod: number): string {
|
||||||
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the text color class for stats affected by weak/elite adjustment. */
|
||||||
|
function adjustmentColor(adjustment: "weak" | "elite" | undefined): string {
|
||||||
|
if (adjustment === "elite") return "text-blue-400";
|
||||||
|
if (adjustment === "weak") return "text-red-400";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpellLinkProps {
|
||||||
|
readonly spell: SpellReference;
|
||||||
|
readonly onOpen: (spell: SpellReference, rect: DOMRect) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsesPerDay({ count }: Readonly<{ count: number | undefined }>) {
|
||||||
|
if (count === undefined || count <= 1) return null;
|
||||||
|
return <span> (×{count})</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellLink({ spell, onOpen }: Readonly<SpellLinkProps>) {
|
||||||
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!spell.description) return;
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (rect) onOpen(spell, rect);
|
||||||
|
}, [spell, onOpen]);
|
||||||
|
|
||||||
|
if (!spell.description) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{spell.name}
|
||||||
|
<UsesPerDay count={spell.usesPerDay} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className="cursor-pointer text-foreground underline decoration-dotted underline-offset-2 hover:text-hover-neutral"
|
||||||
|
>
|
||||||
|
{spell.name}
|
||||||
|
</button>
|
||||||
|
<UsesPerDay count={spell.usesPerDay} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpellListLineProps {
|
||||||
|
readonly label: string;
|
||||||
|
readonly spells: readonly SpellReference[];
|
||||||
|
readonly onOpen: (spell: SpellReference, rect: DOMRect) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellListLine({
|
||||||
|
label,
|
||||||
|
spells,
|
||||||
|
onOpen,
|
||||||
|
}: Readonly<SpellListLineProps>) {
|
||||||
|
return (
|
||||||
|
<div className="pl-2">
|
||||||
|
<span className="font-semibold">{label}:</span>{" "}
|
||||||
|
{spells.map((spell, i) => (
|
||||||
|
<span key={spell.slug ?? spell.name}>
|
||||||
|
{i > 0 ? ", " : ""}
|
||||||
|
<SpellLink spell={spell} onOpen={onOpen} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipmentLinkProps {
|
||||||
|
readonly item: EquipmentItem;
|
||||||
|
readonly onOpen: (item: EquipmentItem, rect: DOMRect) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EquipmentLink({ item, onOpen }: Readonly<EquipmentLinkProps>) {
|
||||||
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!item.description) return;
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (rect) onOpen(item, rect);
|
||||||
|
}, [item, onOpen]);
|
||||||
|
|
||||||
|
if (!item.description) {
|
||||||
|
return <span>{item.name}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className="cursor-pointer text-foreground underline decoration-dotted underline-offset-2 hover:text-hover-neutral"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pf2eStatBlock({
|
||||||
|
creature,
|
||||||
|
adjustment,
|
||||||
|
combatantId,
|
||||||
|
baseCreature,
|
||||||
|
onSetAdjustment,
|
||||||
|
}: Readonly<Pf2eStatBlockProps>) {
|
||||||
|
const [openSpell, setOpenSpell] = useState<{
|
||||||
|
spell: SpellReference;
|
||||||
|
rect: DOMRect;
|
||||||
|
} | null>(null);
|
||||||
|
const handleOpenSpell = useCallback(
|
||||||
|
(spell: SpellReference, rect: DOMRect) => setOpenSpell({ spell, rect }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleCloseSpell = useCallback(() => setOpenSpell(null), []);
|
||||||
|
const [openEquipment, setOpenEquipment] = useState<{
|
||||||
|
item: EquipmentItem;
|
||||||
|
rect: DOMRect;
|
||||||
|
} | null>(null);
|
||||||
|
const handleOpenEquipment = useCallback(
|
||||||
|
(item: EquipmentItem, rect: DOMRect) => setOpenEquipment({ item, rect }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleCloseEquipment = useCallback(() => setOpenEquipment(null), []);
|
||||||
|
|
||||||
|
const rk = recallKnowledge(creature.level, creature.traits);
|
||||||
|
const adjColor = adjustmentColor(adjustment);
|
||||||
|
|
||||||
|
const abilityEntries = [
|
||||||
|
{ label: "Str", mod: creature.abilityMods.str },
|
||||||
|
{ label: "Dex", mod: creature.abilityMods.dex },
|
||||||
|
{ label: "Con", mod: creature.abilityMods.con },
|
||||||
|
{ label: "Int", mod: creature.abilityMods.int },
|
||||||
|
{ label: "Wis", mod: creature.abilityMods.wis },
|
||||||
|
{ label: "Cha", mod: creature.abilityMods.cha },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 text-foreground">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<h2 className="flex items-center gap-1.5 font-bold text-stat-heading text-xl">
|
||||||
|
{adjustment === "elite" && (
|
||||||
|
<ChevronUp className="h-5 w-5 shrink-0 text-blue-400" />
|
||||||
|
)}
|
||||||
|
{adjustment === "weak" && (
|
||||||
|
<ChevronDown className="h-5 w-5 shrink-0 text-red-400" />
|
||||||
|
)}
|
||||||
|
{creature.name}
|
||||||
|
</h2>
|
||||||
|
<span className={cn("shrink-0 font-semibold text-sm", adjColor)}>
|
||||||
|
Level {creature.level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{combatantId != null &&
|
||||||
|
onSetAdjustment != null &&
|
||||||
|
baseCreature != null && (
|
||||||
|
<div className="mt-1 flex gap-1">
|
||||||
|
{(["weak", "normal", "elite"] as const).map((opt) => {
|
||||||
|
const value = opt === "normal" ? undefined : opt;
|
||||||
|
const isActive = adjustment === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"rounded px-2 py-0.5 font-medium text-xs capitalize",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-primary-foreground"
|
||||||
|
: "bg-card text-muted-foreground hover:bg-accent/30",
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
onSetAdjustment(combatantId, value, baseCreature)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{displayTraits(creature.traits).map((trait) => (
|
||||||
|
<span
|
||||||
|
key={trait}
|
||||||
|
className="rounded border border-border bg-card px-1.5 py-0.5 text-foreground text-xs"
|
||||||
|
>
|
||||||
|
{trait}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
|
{creature.sourceDisplayName}
|
||||||
|
</p>
|
||||||
|
{rk && (
|
||||||
|
<p className="mt-1 text-sm">
|
||||||
|
<span className="font-semibold">Recall Knowledge</span> DC {rk.dc}{" "}
|
||||||
|
• {capitalize(rk.type)} ({rk.skills.join("/")})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* Perception, Languages, Skills */}
|
||||||
|
<div className="space-y-0.5 text-sm">
|
||||||
|
<div className={adjColor}>
|
||||||
|
<span className="font-semibold">Perception</span>{" "}
|
||||||
|
{formatInitiativeModifier(creature.perception)}
|
||||||
|
{creature.senses || creature.perceptionDetails
|
||||||
|
? `; ${[creature.senses, creature.perceptionDetails].filter(Boolean).join(", ")}`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
<PropertyLine label="Languages" value={creature.languages} />
|
||||||
|
<PropertyLine label="Skills" value={creature.skills} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ability Modifiers */}
|
||||||
|
<div className="grid grid-cols-6 gap-1 text-center text-sm">
|
||||||
|
{abilityEntries.map((a) => (
|
||||||
|
<div key={a.label}>
|
||||||
|
<div className="font-semibold text-muted-foreground text-xs">
|
||||||
|
{a.label}
|
||||||
|
</div>
|
||||||
|
<div>{formatMod(a.mod)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PropertyLine label="Items" value={creature.items} />
|
||||||
|
|
||||||
|
{/* Top abilities (before defenses) */}
|
||||||
|
<TraitSection entries={creature.abilitiesTop} />
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* Defenses */}
|
||||||
|
<div className="space-y-0.5 text-sm">
|
||||||
|
<div className={adjColor}>
|
||||||
|
<span className="font-semibold">AC</span> {creature.ac}
|
||||||
|
{creature.acConditional ? ` (${creature.acConditional})` : ""};{" "}
|
||||||
|
<span className="font-semibold">Fort</span>{" "}
|
||||||
|
{formatMod(creature.saveFort)},{" "}
|
||||||
|
<span className="font-semibold">Ref</span>{" "}
|
||||||
|
{formatMod(creature.saveRef)},{" "}
|
||||||
|
<span className="font-semibold">Will</span>{" "}
|
||||||
|
{formatMod(creature.saveWill)}
|
||||||
|
{creature.saveConditional ? `; ${creature.saveConditional}` : ""}
|
||||||
|
</div>
|
||||||
|
<div className={adjColor}>
|
||||||
|
<span className="font-semibold">HP</span> {creature.hp}
|
||||||
|
{creature.hpDetails ? ` (${creature.hpDetails})` : ""}
|
||||||
|
</div>
|
||||||
|
<PropertyLine label="Immunities" value={creature.immunities} />
|
||||||
|
<PropertyLine label="Resistances" value={creature.resistances} />
|
||||||
|
<PropertyLine label="Weaknesses" value={creature.weaknesses} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mid abilities (reactions, auras) */}
|
||||||
|
<TraitSection entries={creature.abilitiesMid} />
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* Speed */}
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-semibold">Speed</span> {creature.speed}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attacks */}
|
||||||
|
<TraitSection entries={creature.attacks} />
|
||||||
|
|
||||||
|
{/* Bottom abilities (active abilities) */}
|
||||||
|
<TraitSection entries={creature.abilitiesBot} />
|
||||||
|
|
||||||
|
{/* Spellcasting */}
|
||||||
|
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
{creature.spellcasting.map((sc) => (
|
||||||
|
<div key={sc.name} className="space-y-1 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold italic">{sc.name}.</span>{" "}
|
||||||
|
{sc.headerText}
|
||||||
|
</div>
|
||||||
|
{sc.daily?.map((d) => (
|
||||||
|
<SpellListLine
|
||||||
|
key={d.uses}
|
||||||
|
label={d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}
|
||||||
|
spells={d.spells}
|
||||||
|
onOpen={handleOpenSpell}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{sc.atWill && sc.atWill.length > 0 && (
|
||||||
|
<SpellListLine
|
||||||
|
label="Cantrips"
|
||||||
|
spells={sc.atWill}
|
||||||
|
onOpen={handleOpenSpell}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{creature.equipment && creature.equipment.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
<h3 className="font-bold text-base text-stat-heading">Equipment</h3>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
{creature.equipment.map((item) => (
|
||||||
|
<div key={item.name}>
|
||||||
|
<EquipmentLink item={item} onOpen={handleOpenEquipment} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{openSpell ? (
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={openSpell.spell}
|
||||||
|
anchorRect={openSpell.rect}
|
||||||
|
onClose={handleCloseSpell}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{openEquipment ? (
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={openEquipment.item}
|
||||||
|
anchorRect={openEquipment.rect}
|
||||||
|
onClose={handleCloseEquipment}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/web/src/components/rich-description.tsx
Normal file
20
apps/web/src/components/rich-description.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders text containing safe HTML formatting tags (strong, em, ul, ol, li)
|
||||||
|
* preserved by the stripFoundryTags pipeline. All other HTML is already
|
||||||
|
* stripped before reaching this component.
|
||||||
|
*/
|
||||||
|
export function RichDescription({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
}: Readonly<{ text: string; className?: string }>) {
|
||||||
|
const props = {
|
||||||
|
className: cn(
|
||||||
|
"[&_ol]:list-decimal [&_ol]:pl-4 [&_ul]:list-disc [&_ul]:pl-4",
|
||||||
|
className,
|
||||||
|
),
|
||||||
|
dangerouslySetInnerHTML: { __html: text },
|
||||||
|
};
|
||||||
|
return <div {...props} />;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ interface SettingsModalProps {
|
|||||||
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
|
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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const THEME_OPTIONS: {
|
const THEME_OPTIONS: {
|
||||||
@@ -36,7 +37,7 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
|||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<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">
|
||||||
Conditions
|
Game System
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{EDITION_OPTIONS.map((opt) => (
|
{EDITION_OPTIONS.map((opt) => (
|
||||||
|
|||||||
@@ -2,23 +2,26 @@ import { Download, Loader2, Upload } from "lucide-react";
|
|||||||
import { useId, useRef, useState } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import { useAdapters } from "../contexts/adapter-context.js";
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
interface SourceFetchPromptProps {
|
interface SourceFetchPromptProps {
|
||||||
sourceCode: string;
|
sourceCode: string;
|
||||||
onSourceLoaded: () => void;
|
onSourceLoaded: (skippedNames: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SourceFetchPrompt({
|
export function SourceFetchPrompt({
|
||||||
sourceCode,
|
sourceCode,
|
||||||
onSourceLoaded,
|
onSourceLoaded,
|
||||||
}: Readonly<SourceFetchPromptProps>) {
|
}: Readonly<SourceFetchPromptProps>) {
|
||||||
const { bestiaryIndex } = useAdapters();
|
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||||
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
||||||
const sourceDisplayName = bestiaryIndex.getSourceDisplayName(sourceCode);
|
const { edition } = useRulesEditionContext();
|
||||||
|
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
||||||
|
const sourceDisplayName = indexPort.getSourceDisplayName(sourceCode);
|
||||||
const [url, setUrl] = useState(() =>
|
const [url, setUrl] = useState(() =>
|
||||||
bestiaryIndex.getDefaultFetchUrl(sourceCode),
|
indexPort.getDefaultFetchUrl(sourceCode),
|
||||||
);
|
);
|
||||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
@@ -29,8 +32,9 @@ export function SourceFetchPrompt({
|
|||||||
setStatus("fetching");
|
setStatus("fetching");
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await fetchAndCacheSource(sourceCode, url);
|
const { skippedNames } = await fetchAndCacheSource(sourceCode, url);
|
||||||
onSourceLoaded();
|
setStatus("idle");
|
||||||
|
onSourceLoaded(skippedNames);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setError(e instanceof Error ? e.message : "Failed to fetch source data");
|
setError(e instanceof Error ? e.message : "Failed to fetch source data");
|
||||||
@@ -48,7 +52,7 @@ export function SourceFetchPrompt({
|
|||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const json = JSON.parse(text);
|
const json = JSON.parse(text);
|
||||||
await uploadAndCacheSource(sourceCode, json);
|
await uploadAndCacheSource(sourceCode, json);
|
||||||
onSourceLoaded();
|
onSourceLoaded([]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setError(
|
setError(
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import {
|
|||||||
import type { CachedSourceInfo } from "../adapters/ports.js";
|
import type { CachedSourceInfo } from "../adapters/ports.js";
|
||||||
import { useAdapters } from "../contexts/adapter-context.js";
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
export function SourceManager() {
|
export function SourceManager() {
|
||||||
const { bestiaryCache } = useAdapters();
|
const { bestiaryCache } = useAdapters();
|
||||||
const { refreshCache } = useBestiaryContext();
|
const { refreshCache } = useBestiaryContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
const system = edition === "pf2e" ? "pf2e" : "dnd";
|
||||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [optimisticSources, applyOptimistic] = useOptimistic(
|
const [optimisticSources, applyOptimistic] = useOptimistic(
|
||||||
@@ -29,9 +32,9 @@ export function SourceManager() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const loadSources = useCallback(async () => {
|
const loadSources = useCallback(async () => {
|
||||||
const cached = await bestiaryCache.getCachedSources();
|
const cached = await bestiaryCache.getCachedSources(system);
|
||||||
setSources(cached);
|
setSources(cached);
|
||||||
}, [bestiaryCache]);
|
}, [bestiaryCache, system]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSources();
|
void loadSources();
|
||||||
@@ -39,7 +42,7 @@ export function SourceManager() {
|
|||||||
|
|
||||||
const handleClearSource = async (sourceCode: string) => {
|
const handleClearSource = async (sourceCode: string) => {
|
||||||
applyOptimistic({ type: "remove", sourceCode });
|
applyOptimistic({ type: "remove", sourceCode });
|
||||||
await bestiaryCache.clearSource(sourceCode);
|
await bestiaryCache.clearSource(system, sourceCode);
|
||||||
await loadSources();
|
await loadSources();
|
||||||
void refreshCache();
|
void refreshCache();
|
||||||
};
|
};
|
||||||
|
|||||||
178
apps/web/src/components/spell-detail-popover.tsx
Normal file
178
apps/web/src/components/spell-detail-popover.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import type { ActivityCost, SpellReference } from "@initiative/domain";
|
||||||
|
import { DetailPopover } from "./detail-popover.js";
|
||||||
|
import { RichDescription } from "./rich-description.js";
|
||||||
|
import { ActivityIcon } from "./stat-block-parts.js";
|
||||||
|
|
||||||
|
interface SpellDetailPopoverProps {
|
||||||
|
readonly spell: SpellReference;
|
||||||
|
readonly anchorRect: DOMRect;
|
||||||
|
readonly onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANK_LABELS = [
|
||||||
|
"Cantrip",
|
||||||
|
"1st",
|
||||||
|
"2nd",
|
||||||
|
"3rd",
|
||||||
|
"4th",
|
||||||
|
"5th",
|
||||||
|
"6th",
|
||||||
|
"7th",
|
||||||
|
"8th",
|
||||||
|
"9th",
|
||||||
|
"10th",
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatRank(rank: number | undefined): string {
|
||||||
|
if (rank === undefined) return "";
|
||||||
|
return RANK_LABELS[rank] ?? `Rank ${rank}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActionCost(cost: string): ActivityCost | null {
|
||||||
|
if (cost === "free") return { number: 1, unit: "free" };
|
||||||
|
if (cost === "reaction") return { number: 1, unit: "reaction" };
|
||||||
|
const n = Number(cost);
|
||||||
|
if (n >= 1 && n <= 3) return { number: n, unit: "action" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellActionCost({ cost }: Readonly<{ cost: string | undefined }>) {
|
||||||
|
if (!cost) return null;
|
||||||
|
const activity = parseActionCost(cost);
|
||||||
|
if (activity) {
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 text-lg">
|
||||||
|
<ActivityIcon activity={activity} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="shrink-0 text-muted-foreground text-xs">{cost}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellHeader({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="font-bold text-lg text-stat-heading">{spell.name}</h3>
|
||||||
|
<SpellActionCost cost={spell.actionCost} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellTraits({ traits }: Readonly<{ traits: readonly string[] }>) {
|
||||||
|
if (traits.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{traits.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="rounded border border-border bg-card px-1.5 py-0.5 text-foreground text-xs"
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LabeledValue({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: Readonly<{ label: string; value: string }>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="font-semibold">{label}</span> {value}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellRangeLine({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
const items: { label: string; value: string }[] = [];
|
||||||
|
if (spell.range) items.push({ label: "Range", value: spell.range });
|
||||||
|
if (spell.target) items.push({ label: "Target", value: spell.target });
|
||||||
|
if (spell.area) items.push({ label: "Area", value: spell.area });
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<span key={item.label}>
|
||||||
|
{i > 0 ? "; " : ""}
|
||||||
|
<LabeledValue label={item.label} value={item.value} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellMeta({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
const hasTraditions =
|
||||||
|
spell.traditions !== undefined && spell.traditions.length > 0;
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5 text-xs">
|
||||||
|
{spell.rank === undefined ? null : (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">{formatRank(spell.rank)}</span>
|
||||||
|
{hasTraditions ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
({spell.traditions?.join(", ")})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SpellRangeLine spell={spell} />
|
||||||
|
{spell.duration ? (
|
||||||
|
<div>
|
||||||
|
<LabeledValue label="Duration" value={spell.duration} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{spell.defense ? (
|
||||||
|
<div>
|
||||||
|
<LabeledValue label="Defense" value={spell.defense} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<SpellHeader spell={spell} />
|
||||||
|
<SpellTraits traits={spell.traits ?? []} />
|
||||||
|
<SpellMeta spell={spell} />
|
||||||
|
{spell.description ? (
|
||||||
|
<RichDescription
|
||||||
|
text={spell.description}
|
||||||
|
className="whitespace-pre-line text-foreground"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground italic">
|
||||||
|
No description available.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{spell.heightening ? (
|
||||||
|
<RichDescription
|
||||||
|
text={spell.heightening}
|
||||||
|
className="whitespace-pre-line text-foreground text-xs"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpellDetailPopover({
|
||||||
|
spell,
|
||||||
|
anchorRect,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<SpellDetailPopoverProps>) {
|
||||||
|
return (
|
||||||
|
<DetailPopover
|
||||||
|
anchorRect={anchorRect}
|
||||||
|
onClose={onClose}
|
||||||
|
ariaLabel={`Spell details: ${spell.name}`}
|
||||||
|
>
|
||||||
|
<SpellDetailContent spell={spell} />
|
||||||
|
</DetailPopover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,26 @@
|
|||||||
import type { CreatureId } from "@initiative/domain";
|
import type {
|
||||||
|
AnyCreature,
|
||||||
|
Combatant,
|
||||||
|
CombatantId,
|
||||||
|
Creature,
|
||||||
|
CreatureId,
|
||||||
|
Pf2eCreature,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { applyPf2eAdjustment } from "@initiative/domain";
|
||||||
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||||
|
import { DndStatBlock } from "./dnd-stat-block.js";
|
||||||
|
import { Pf2eStatBlock } from "./pf2e-stat-block.js";
|
||||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||||
import { SourceManager } from "./source-manager.js";
|
import { SourceManager } from "./source-manager.js";
|
||||||
import { StatBlock } from "./stat-block.js";
|
import { Toast } from "./toast.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
interface StatBlockPanelProps {
|
interface StatBlockPanelProps {
|
||||||
@@ -20,7 +31,10 @@ interface StatBlockPanelProps {
|
|||||||
function extractSourceCode(cId: CreatureId): string {
|
function extractSourceCode(cId: CreatureId): string {
|
||||||
const colonIndex = cId.indexOf(":");
|
const colonIndex = cId.indexOf(":");
|
||||||
if (colonIndex === -1) return "";
|
if (colonIndex === -1) return "";
|
||||||
return cId.slice(0, colonIndex).toUpperCase();
|
const prefix = cId.slice(0, colonIndex);
|
||||||
|
// D&D source codes are short uppercase (e.g. "mm" from "MM").
|
||||||
|
// PF2e source codes use hyphens (e.g. "pathfinder-monster-core").
|
||||||
|
return prefix.includes("-") ? prefix : prefix.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsedTab({
|
function CollapsedTab({
|
||||||
@@ -211,6 +225,7 @@ function MobileDrawer({
|
|||||||
function usePanelRole(panelRole: "browse" | "pinned") {
|
function usePanelRole(panelRole: "browse" | "pinned") {
|
||||||
const sidePanel = useSidePanelContext();
|
const sidePanel = useSidePanelContext();
|
||||||
const { getCreature } = useBestiaryContext();
|
const { getCreature } = useBestiaryContext();
|
||||||
|
const { encounter, setCreatureAdjustment } = useEncounterContext();
|
||||||
|
|
||||||
const creatureId =
|
const creatureId =
|
||||||
panelRole === "browse"
|
panelRole === "browse"
|
||||||
@@ -218,10 +233,18 @@ function usePanelRole(panelRole: "browse" | "pinned") {
|
|||||||
: sidePanel.pinnedCreatureId;
|
: sidePanel.pinnedCreatureId;
|
||||||
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
|
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
|
||||||
|
|
||||||
|
const combatantId =
|
||||||
|
panelRole === "browse" ? sidePanel.selectedCombatantId : null;
|
||||||
|
const combatant = combatantId
|
||||||
|
? (encounter.combatants.find((c) => c.id === combatantId) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
const isBrowse = panelRole === "browse";
|
const isBrowse = panelRole === "browse";
|
||||||
return {
|
return {
|
||||||
creatureId,
|
creatureId,
|
||||||
creature,
|
creature,
|
||||||
|
combatant,
|
||||||
|
setCreatureAdjustment,
|
||||||
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
||||||
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
||||||
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
||||||
@@ -233,14 +256,42 @@ function usePanelRole(panelRole: "browse" | "pinned") {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStatBlock(
|
||||||
|
creature: AnyCreature,
|
||||||
|
combatant: Combatant | null,
|
||||||
|
setCreatureAdjustment: (
|
||||||
|
id: CombatantId,
|
||||||
|
adj: "weak" | "elite" | undefined,
|
||||||
|
base: Pf2eCreature,
|
||||||
|
) => void,
|
||||||
|
) {
|
||||||
|
if ("system" in creature && creature.system === "pf2e") {
|
||||||
|
const baseCreature = creature;
|
||||||
|
const adjusted = combatant?.creatureAdjustment
|
||||||
|
? applyPf2eAdjustment(baseCreature, combatant.creatureAdjustment)
|
||||||
|
: baseCreature;
|
||||||
|
return (
|
||||||
|
<Pf2eStatBlock
|
||||||
|
creature={adjusted}
|
||||||
|
adjustment={combatant?.creatureAdjustment}
|
||||||
|
combatantId={combatant?.id}
|
||||||
|
baseCreature={baseCreature}
|
||||||
|
onSetAdjustment={setCreatureAdjustment}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <DndStatBlock creature={creature as Creature} />;
|
||||||
|
}
|
||||||
|
|
||||||
export function StatBlockPanel({
|
export function StatBlockPanel({
|
||||||
panelRole,
|
panelRole,
|
||||||
side,
|
side,
|
||||||
}: Readonly<StatBlockPanelProps>) {
|
}: Readonly<StatBlockPanelProps>) {
|
||||||
const { isSourceCached } = useBestiaryContext();
|
|
||||||
const {
|
const {
|
||||||
creatureId,
|
creatureId,
|
||||||
creature,
|
creature,
|
||||||
|
combatant,
|
||||||
|
setCreatureAdjustment,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
@@ -256,6 +307,7 @@ export function StatBlockPanel({
|
|||||||
);
|
);
|
||||||
const [needsFetch, setNeedsFetch] = useState(false);
|
const [needsFetch, setNeedsFetch] = useState(false);
|
||||||
const [checkingCache, setCheckingCache] = useState(false);
|
const [checkingCache, setCheckingCache] = useState(false);
|
||||||
|
const [skippedToast, setSkippedToast] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
||||||
@@ -276,19 +328,23 @@ export function StatBlockPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCheckingCache(true);
|
// Show fetch prompt both when source is uncached AND when the source is
|
||||||
void isSourceCached(sourceCode).then((cached) => {
|
// cached but this specific creature is missing (e.g. skipped by ad blocker).
|
||||||
setNeedsFetch(!cached);
|
setNeedsFetch(true);
|
||||||
setCheckingCache(false);
|
setCheckingCache(false);
|
||||||
});
|
}, [creatureId, creature]);
|
||||||
}, [creatureId, creature, isSourceCached]);
|
|
||||||
|
|
||||||
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
||||||
|
|
||||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||||
|
|
||||||
const handleSourceLoaded = () => {
|
const handleSourceLoaded = (skippedNames: string[]) => {
|
||||||
setNeedsFetch(false);
|
if (skippedNames.length > 0) {
|
||||||
|
const names = skippedNames.join(", ");
|
||||||
|
setSkippedToast(
|
||||||
|
`${skippedNames.length} creature(s) skipped (ad blocker?): ${names}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
@@ -307,7 +363,7 @@ export function StatBlockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (creature) {
|
if (creature) {
|
||||||
return <StatBlock creature={creature} />;
|
return renderStatBlock(creature, combatant, setCreatureAdjustment);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsFetch && sourceCode) {
|
if (needsFetch && sourceCode) {
|
||||||
@@ -331,8 +387,13 @@ export function StatBlockPanel({
|
|||||||
else if (bulkImportMode) fallbackName = "Import All Sources";
|
else if (bulkImportMode) fallbackName = "Import All Sources";
|
||||||
const creatureName = creature?.name ?? fallbackName;
|
const creatureName = creature?.name ?? fallbackName;
|
||||||
|
|
||||||
|
const toast = skippedToast ? (
|
||||||
|
<Toast message={skippedToast} onDismiss={() => setSkippedToast(null)} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<DesktopPanel
|
<DesktopPanel
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
side={side}
|
side={side}
|
||||||
@@ -345,10 +406,17 @@ export function StatBlockPanel({
|
|||||||
>
|
>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</DesktopPanel>
|
</DesktopPanel>
|
||||||
|
{toast}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (panelRole === "pinned" || isCollapsed) return null;
|
if (panelRole === "pinned" || isCollapsed) return null;
|
||||||
|
|
||||||
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
|
return (
|
||||||
|
<>
|
||||||
|
<MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>
|
||||||
|
{toast}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
183
apps/web/src/components/stat-block-parts.tsx
Normal file
183
apps/web/src/components/stat-block-parts.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import type {
|
||||||
|
ActivityCost,
|
||||||
|
TraitBlock,
|
||||||
|
TraitSegment,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { RichDescription } from "./rich-description.js";
|
||||||
|
|
||||||
|
export function PropertyLine({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: Readonly<{
|
||||||
|
label: string;
|
||||||
|
value: string | undefined;
|
||||||
|
}>) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-semibold">{label}</span> {value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionDivider() {
|
||||||
|
return (
|
||||||
|
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentKey(seg: TraitSegment): string {
|
||||||
|
return seg.type === "text"
|
||||||
|
? seg.value.slice(0, 40)
|
||||||
|
: seg.items.map((i) => i.label ?? i.text.slice(0, 20)).join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraitSegments({
|
||||||
|
segments,
|
||||||
|
}: Readonly<{ segments: readonly TraitSegment[] }>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{segments.map((seg, i) => {
|
||||||
|
if (seg.type === "text") {
|
||||||
|
return (
|
||||||
|
<RichDescription
|
||||||
|
key={segmentKey(seg)}
|
||||||
|
text={i === 0 ? ` ${seg.value}` : seg.value}
|
||||||
|
className="inline whitespace-pre-line"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
|
||||||
|
{seg.items.map((item) => (
|
||||||
|
<div key={item.label ?? item.text}>
|
||||||
|
{item.label != null && (
|
||||||
|
<span className="font-semibold">{item.label}. </span>
|
||||||
|
)}
|
||||||
|
<RichDescription text={item.text} className="inline" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_DIAMOND = "M50 2 L96 50 L50 98 L4 50 Z M48 27 L71 50 L48 73 Z";
|
||||||
|
const ACTION_DIAMOND_SOLID = "M50 2 L96 50 L50 98 L4 50 Z";
|
||||||
|
const ACTION_DIAMOND_OUTLINE =
|
||||||
|
"M90 2 L136 50 L90 98 L44 50 Z M90 29 L111 50 L90 71 L69 50 Z";
|
||||||
|
const FREE_ACTION_DIAMOND =
|
||||||
|
"M50 2 L96 50 L50 98 L4 50 Z M50 12 L12 50 L50 88 L88 50 Z";
|
||||||
|
const FREE_ACTION_CHEVRON = "M48 27 L71 50 L48 73 Z";
|
||||||
|
const REACTION_ARROW =
|
||||||
|
"M75 15 A42 42 0 1 0 85 55 L72 55 A30 30 0 1 1 65 25 L65 40 L92 20 L65 0 L65 15 Z";
|
||||||
|
|
||||||
|
export function ActivityIcon({
|
||||||
|
activity,
|
||||||
|
}: Readonly<{ activity: ActivityCost }>) {
|
||||||
|
const cls = "inline-block h-[1em] align-[-0.1em]";
|
||||||
|
if (activity.unit === "free") {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className={cls} viewBox="0 0 100 100">
|
||||||
|
<path d={FREE_ACTION_DIAMOND} fill="currentColor" fillRule="evenodd" />
|
||||||
|
<path d={FREE_ACTION_CHEVRON} fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (activity.unit === "reaction") {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className={cls} viewBox="0 0 100 100">
|
||||||
|
<g transform="translate(100,100) rotate(180)">
|
||||||
|
<path d={REACTION_ARROW} fill="currentColor" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const count = activity.number;
|
||||||
|
if (count === 1) {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className={cls} viewBox="0 0 100 100">
|
||||||
|
<path d={ACTION_DIAMOND} fill="currentColor" fillRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (count === 2) {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className={cls} viewBox="0 0 140 100">
|
||||||
|
<path d={ACTION_DIAMOND_SOLID} fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d={ACTION_DIAMOND_OUTLINE}
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className={cls} viewBox="0 0 180 100">
|
||||||
|
<path d={ACTION_DIAMOND_SOLID} fill="currentColor" />
|
||||||
|
<path d="M90 2 L136 50 L90 98 L44 50 Z" fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d="M130 2 L176 50 L130 98 L84 50 Z M130 29 L151 50 L130 71 L109 50 Z"
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-semibold italic">
|
||||||
|
{trait.name}
|
||||||
|
{trait.activity ? null : "."}
|
||||||
|
{trait.activity ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<ActivityIcon activity={trait.activity} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{trait.frequency ? ` (${trait.frequency})` : null}
|
||||||
|
{trait.trigger ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<span className="font-semibold">Trigger</span> {trait.trigger}
|
||||||
|
{trait.segments.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<span className="font-semibold">Effect</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<TraitSegments segments={trait.segments} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TraitSection({
|
||||||
|
entries,
|
||||||
|
heading,
|
||||||
|
}: Readonly<{
|
||||||
|
entries: readonly TraitBlock[] | undefined;
|
||||||
|
heading?: string;
|
||||||
|
}>) {
|
||||||
|
if (!entries || entries.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
{heading ? (
|
||||||
|
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<TraitEntry key={e.name} trait={e} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { useDifficulty } from "../hooks/use-difficulty.js";
|
import { useDifficulty } from "../hooks/use-difficulty.js";
|
||||||
import { DifficultyBreakdownPanel } from "./difficulty-breakdown-panel.js";
|
import { DifficultyBreakdownPanel } from "./difficulty-breakdown-panel.js";
|
||||||
import { DifficultyIndicator } from "./difficulty-indicator.js";
|
import {
|
||||||
|
DifficultyIndicator,
|
||||||
|
TIER_LABELS_5_5E,
|
||||||
|
TIER_LABELS_2014,
|
||||||
|
} 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";
|
||||||
|
|
||||||
@@ -20,6 +25,8 @@ export function TurnNavigation() {
|
|||||||
} = useEncounterContext();
|
} = useEncounterContext();
|
||||||
|
|
||||||
const difficulty = useDifficulty();
|
const difficulty = useDifficulty();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
const tierLabels = edition === "5e" ? TIER_LABELS_2014 : TIER_LABELS_5_5E;
|
||||||
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;
|
||||||
@@ -79,6 +86,7 @@ export function TurnNavigation() {
|
|||||||
<div className="relative mr-1">
|
<div className="relative mr-1">
|
||||||
<DifficultyIndicator
|
<DifficultyIndicator
|
||||||
result={difficulty}
|
result={difficulty}
|
||||||
|
labels={tierLabels}
|
||||||
onClick={() => setShowBreakdown((prev) => !prev)}
|
onClick={() => setShowBreakdown((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
{showBreakdown ? (
|
{showBreakdown ? (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
BestiaryCachePort,
|
BestiaryCachePort,
|
||||||
BestiaryIndexPort,
|
BestiaryIndexPort,
|
||||||
EncounterPersistence,
|
EncounterPersistence,
|
||||||
|
Pf2eBestiaryIndexPort,
|
||||||
PlayerCharacterPersistence,
|
PlayerCharacterPersistence,
|
||||||
UndoRedoPersistence,
|
UndoRedoPersistence,
|
||||||
} from "../adapters/ports.js";
|
} from "../adapters/ports.js";
|
||||||
@@ -13,6 +14,7 @@ export interface Adapters {
|
|||||||
playerCharacterPersistence: PlayerCharacterPersistence;
|
playerCharacterPersistence: PlayerCharacterPersistence;
|
||||||
bestiaryCache: BestiaryCachePort;
|
bestiaryCache: BestiaryCachePort;
|
||||||
bestiaryIndex: BestiaryIndexPort;
|
bestiaryIndex: BestiaryIndexPort;
|
||||||
|
pf2eBestiaryIndex: Pf2eBestiaryIndexPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AdapterContext = createContext<Adapters | null>(null);
|
const AdapterContext = createContext<Adapters | null>(null);
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import type {
|
import type { ConditionId, PlayerCharacter } from "@initiative/domain";
|
||||||
BestiaryIndexEntry,
|
|
||||||
ConditionId,
|
|
||||||
PlayerCharacter,
|
|
||||||
} from "@initiative/domain";
|
|
||||||
import {
|
import {
|
||||||
combatantId,
|
combatantId,
|
||||||
createEncounter,
|
createEncounter,
|
||||||
@@ -11,6 +7,7 @@ import {
|
|||||||
playerCharacterId,
|
playerCharacterId,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { SearchResult } from "../use-bestiary.js";
|
||||||
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
||||||
|
|
||||||
function emptyState(): EncounterState {
|
function emptyState(): EncounterState {
|
||||||
@@ -45,9 +42,11 @@ function stateWithHp(name: string, maxHp: number): EncounterState {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const BESTIARY_ENTRY: BestiaryIndexEntry = {
|
const BESTIARY_ENTRY: SearchResult = {
|
||||||
|
system: "dnd",
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
source: "MM",
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
ac: 15,
|
ac: 15,
|
||||||
hp: 7,
|
hp: 7,
|
||||||
dex: 14,
|
dex: 14,
|
||||||
@@ -57,6 +56,19 @@ const BESTIARY_ENTRY: BestiaryIndexEntry = {
|
|||||||
type: "humanoid",
|
type: "humanoid",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PF2E_BESTIARY_ENTRY: SearchResult = {
|
||||||
|
system: "pf2e",
|
||||||
|
name: "Goblin Warrior",
|
||||||
|
source: "B1",
|
||||||
|
sourceDisplayName: "Bestiary",
|
||||||
|
level: -1,
|
||||||
|
ac: 16,
|
||||||
|
hp: 6,
|
||||||
|
perception: 5,
|
||||||
|
size: "small",
|
||||||
|
type: "humanoid",
|
||||||
|
};
|
||||||
|
|
||||||
describe("encounterReducer", () => {
|
describe("encounterReducer", () => {
|
||||||
describe("add-combatant", () => {
|
describe("add-combatant", () => {
|
||||||
it("adds a combatant and pushes undo", () => {
|
it("adds a combatant and pushes undo", () => {
|
||||||
@@ -236,7 +248,9 @@ describe("encounterReducer", () => {
|
|||||||
conditionId: "blinded" as ConditionId,
|
conditionId: "blinded" as ConditionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(next.encounter.combatants[0].conditions).toContain("blinded");
|
expect(next.encounter.combatants[0].conditions).toContainEqual({
|
||||||
|
id: "blinded",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toggles concentration", () => {
|
it("toggles concentration", () => {
|
||||||
@@ -327,6 +341,19 @@ describe("encounterReducer", () => {
|
|||||||
expect(names).toContain("Goblin 1");
|
expect(names).toContain("Goblin 1");
|
||||||
expect(names).toContain("Goblin 2");
|
expect(names).toContain("Goblin 2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds PF2e creature with HP, AC, and creatureId", () => {
|
||||||
|
const next = encounterReducer(emptyState(), {
|
||||||
|
type: "add-from-bestiary",
|
||||||
|
entry: PF2E_BESTIARY_ENTRY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.name).toBe("Goblin Warrior");
|
||||||
|
expect(c.maxHp).toBe(6);
|
||||||
|
expect(c.ac).toBe(16);
|
||||||
|
expect(c.creatureId).toBe("b1:goblin-warrior");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("add-multiple-from-bestiary", () => {
|
describe("add-multiple-from-bestiary", () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} 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";
|
||||||
|
import { useRulesEdition } from "../use-rules-edition.js";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(globalThis, "matchMedia", {
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
@@ -106,7 +107,7 @@ describe("useDifficultyBreakdown", () => {
|
|||||||
expect(result.current).toBeNull();
|
expect(result.current).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns per-combatant entries with correct data", async () => {
|
it("returns per-combatant entries split by side", async () => {
|
||||||
const wrapper = makeWrapper({
|
const wrapper = makeWrapper({
|
||||||
encounter: buildEncounter({
|
encounter: buildEncounter({
|
||||||
combatants: [
|
combatants: [
|
||||||
@@ -145,29 +146,34 @@ describe("useDifficultyBreakdown", () => {
|
|||||||
const breakdown = result.current;
|
const breakdown = result.current;
|
||||||
expect(breakdown).not.toBeNull();
|
expect(breakdown).not.toBeNull();
|
||||||
expect(breakdown?.pcCount).toBe(1);
|
expect(breakdown?.pcCount).toBe(1);
|
||||||
// CR 1/4 = 50 + CR 2 = 450 → total 500
|
// CR 1/4 = 50 + CR 2 = 450 -> total 500
|
||||||
expect(breakdown?.totalMonsterXp).toBe(500);
|
expect(breakdown?.totalMonsterXp).toBe(500);
|
||||||
expect(breakdown?.combatants).toHaveLength(3);
|
|
||||||
|
|
||||||
// Bestiary combatant
|
// PC in party column
|
||||||
const goblin = breakdown?.combatants[0];
|
expect(breakdown?.partyCombatants).toHaveLength(1);
|
||||||
|
expect(breakdown?.partyCombatants[0].combatant.name).toBe("Hero");
|
||||||
|
expect(breakdown?.partyCombatants[0].side).toBe("party");
|
||||||
|
expect(breakdown?.partyCombatants[0].level).toBe(5);
|
||||||
|
|
||||||
|
// Enemies: goblin, thug, bandit
|
||||||
|
expect(breakdown?.enemyCombatants).toHaveLength(3);
|
||||||
|
|
||||||
|
const goblin = breakdown?.enemyCombatants[0];
|
||||||
expect(goblin?.cr).toBe("1/4");
|
expect(goblin?.cr).toBe("1/4");
|
||||||
expect(goblin?.xp).toBe(50);
|
expect(goblin?.xp).toBe(50);
|
||||||
expect(goblin?.source).toBe("SRD");
|
expect(goblin?.source).toBe("SRD");
|
||||||
expect(goblin?.editable).toBe(false);
|
expect(goblin?.editable).toBe(false);
|
||||||
|
expect(goblin?.side).toBe("enemy");
|
||||||
|
|
||||||
// Custom with CR
|
const thug = breakdown?.enemyCombatants[1];
|
||||||
const thug = breakdown?.combatants[1];
|
|
||||||
expect(thug?.cr).toBe("2");
|
expect(thug?.cr).toBe("2");
|
||||||
expect(thug?.xp).toBe(450);
|
expect(thug?.xp).toBe(450);
|
||||||
expect(thug?.source).toBeNull();
|
expect(thug?.source).toBeNull();
|
||||||
expect(thug?.editable).toBe(true);
|
expect(thug?.editable).toBe(true);
|
||||||
|
|
||||||
// Custom without CR
|
const bandit = breakdown?.enemyCombatants[2];
|
||||||
const bandit = breakdown?.combatants[2];
|
|
||||||
expect(bandit?.cr).toBeNull();
|
expect(bandit?.cr).toBeNull();
|
||||||
expect(bandit?.xp).toBeNull();
|
expect(bandit?.xp).toBeNull();
|
||||||
expect(bandit?.source).toBeNull();
|
|
||||||
expect(bandit?.editable).toBe(true);
|
expect(bandit?.editable).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -203,16 +209,15 @@ describe("useDifficultyBreakdown", () => {
|
|||||||
wrapper,
|
wrapper,
|
||||||
});
|
});
|
||||||
|
|
||||||
// With no bestiary creatures loaded, the Ghost has null CR
|
|
||||||
const breakdown = result.current;
|
const breakdown = result.current;
|
||||||
expect(breakdown).not.toBeNull();
|
expect(breakdown).not.toBeNull();
|
||||||
const ghost = breakdown?.combatants[0];
|
const ghost = breakdown?.enemyCombatants[0];
|
||||||
expect(ghost?.cr).toBeNull();
|
expect(ghost?.cr).toBeNull();
|
||||||
expect(ghost?.xp).toBeNull();
|
expect(ghost?.xp).toBeNull();
|
||||||
expect(ghost?.editable).toBe(false);
|
expect(ghost?.editable).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes PC combatants from breakdown entries", async () => {
|
it("PC combatants appear in partyCombatants with level", async () => {
|
||||||
const wrapper = makeWrapper({
|
const wrapper = makeWrapper({
|
||||||
encounter: buildEncounter({
|
encounter: buildEncounter({
|
||||||
combatants: [
|
combatants: [
|
||||||
@@ -239,8 +244,105 @@ describe("useDifficultyBreakdown", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current?.combatants).toHaveLength(1);
|
expect(result.current?.partyCombatants).toHaveLength(1);
|
||||||
expect(result.current?.combatants[0].combatant.name).toBe("Goblin");
|
expect(result.current?.partyCombatants[0].combatant.name).toBe("Hero");
|
||||||
|
expect(result.current?.partyCombatants[0].level).toBe(1);
|
||||||
|
expect(result.current?.partyCombatants[0].side).toBe("party");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("combatant with explicit side override is placed correctly", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Allied Guard",
|
||||||
|
creatureId: goblinCreature.id,
|
||||||
|
side: "party",
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-3"),
|
||||||
|
name: "Thug",
|
||||||
|
cr: "1",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
const breakdown = result.current;
|
||||||
|
expect(breakdown).not.toBeNull();
|
||||||
|
// Allied Guard should be in party column
|
||||||
|
expect(breakdown?.partyCombatants).toHaveLength(2);
|
||||||
|
expect(breakdown?.partyCombatants[1].combatant.name).toBe("Allied Guard");
|
||||||
|
expect(breakdown?.partyCombatants[1].side).toBe("party");
|
||||||
|
// Thug in enemy column
|
||||||
|
expect(breakdown?.enemyCombatants).toHaveLength(1);
|
||||||
|
expect(breakdown?.enemyCombatants[0].combatant.name).toBe("Thug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes encounterMultiplier and adjustedXp for 5e edition", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: goblinCreature.id,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-3"),
|
||||||
|
name: "Thug",
|
||||||
|
cr: "1",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
editionResult.current.setEdition("5e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const breakdown = result.current;
|
||||||
|
expect(breakdown).not.toBeNull();
|
||||||
|
// 2 enemy monsters, 1 PC (<3) → base x1.5, shift up → x2
|
||||||
|
expect(breakdown?.encounterMultiplier).toBe(2);
|
||||||
|
// CR 1/4 (50) + CR 1 (200) = 250, x2 = 500
|
||||||
|
expect(breakdown?.totalMonsterXp).toBe(250);
|
||||||
|
expect(breakdown?.adjustedXp).toBe(500);
|
||||||
|
expect(breakdown?.thresholds).toHaveLength(4);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import type {
|
|
||||||
Combatant,
|
|
||||||
CreatureId,
|
|
||||||
Encounter,
|
|
||||||
PlayerCharacter,
|
|
||||||
} from "@initiative/domain";
|
|
||||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
|
||||||
import { renderHook } from "@testing-library/react";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
|
||||||
useEncounterContext: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
|
||||||
usePlayerCharactersContext: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
|
||||||
useBestiaryContext: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
|
||||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
|
||||||
import { usePlayerCharactersContext } from "../../contexts/player-characters-context.js";
|
|
||||||
import { useDifficulty } from "../use-difficulty.js";
|
|
||||||
|
|
||||||
const mockEncounterContext = vi.mocked(useEncounterContext);
|
|
||||||
const mockPlayerCharactersContext = vi.mocked(usePlayerCharactersContext);
|
|
||||||
const mockBestiaryContext = vi.mocked(useBestiaryContext);
|
|
||||||
|
|
||||||
const pcId1 = playerCharacterId("pc-1");
|
|
||||||
const pcId2 = playerCharacterId("pc-2");
|
|
||||||
const crId1 = creatureId("creature-1");
|
|
||||||
const _crId2 = creatureId("creature-2");
|
|
||||||
|
|
||||||
function setup(options: {
|
|
||||||
combatants: Combatant[];
|
|
||||||
characters: PlayerCharacter[];
|
|
||||||
creatures: Map<CreatureId, { cr: string }>;
|
|
||||||
}) {
|
|
||||||
const encounter = {
|
|
||||||
combatants: options.combatants,
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
} as Encounter;
|
|
||||||
|
|
||||||
mockEncounterContext.mockReturnValue({
|
|
||||||
encounter,
|
|
||||||
} as ReturnType<typeof useEncounterContext>);
|
|
||||||
|
|
||||||
mockPlayerCharactersContext.mockReturnValue({
|
|
||||||
characters: options.characters,
|
|
||||||
} as ReturnType<typeof usePlayerCharactersContext>);
|
|
||||||
|
|
||||||
mockBestiaryContext.mockReturnValue({
|
|
||||||
getCreature: (id: CreatureId) => options.creatures.get(id),
|
|
||||||
} as ReturnType<typeof useBestiaryContext>);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("useDifficulty", () => {
|
|
||||||
it("returns difficulty result for leveled PCs and bestiary monsters", () => {
|
|
||||||
setup({
|
|
||||||
combatants: [
|
|
||||||
{ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1 },
|
|
||||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
|
||||||
],
|
|
||||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
|
|
||||||
creatures: new Map([[crId1, { cr: "1/4" }]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
|
|
||||||
expect(result.current).not.toBeNull();
|
|
||||||
expect(result.current?.tier).toBe("low");
|
|
||||||
expect(result.current?.totalMonsterXp).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("returns null when data is insufficient (ED-2)", () => {
|
|
||||||
it("returns null when encounter has no combatants", () => {
|
|
||||||
setup({ combatants: [], characters: [], creatures: new Map() });
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
expect(result.current).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when only custom combatants (no creatureId)", () => {
|
|
||||||
setup({
|
|
||||||
combatants: [
|
|
||||||
{
|
|
||||||
id: combatantId("c1"),
|
|
||||||
name: "Custom",
|
|
||||||
playerCharacterId: pcId1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }],
|
|
||||||
creatures: new Map(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
expect(result.current).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when bestiary monsters present but no PC combatants", () => {
|
|
||||||
setup({
|
|
||||||
combatants: [
|
|
||||||
{ id: combatantId("c1"), name: "Goblin", creatureId: crId1 },
|
|
||||||
],
|
|
||||||
characters: [],
|
|
||||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
expect(result.current).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when PC combatants have no level", () => {
|
|
||||||
setup({
|
|
||||||
combatants: [
|
|
||||||
{
|
|
||||||
id: combatantId("c1"),
|
|
||||||
name: "Hero",
|
|
||||||
playerCharacterId: pcId1,
|
|
||||||
},
|
|
||||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
|
||||||
],
|
|
||||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
|
||||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
expect(result.current).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when PC combatant references unknown player character", () => {
|
|
||||||
setup({
|
|
||||||
combatants: [
|
|
||||||
{
|
|
||||||
id: combatantId("c1"),
|
|
||||||
name: "Hero",
|
|
||||||
playerCharacterId: pcId2,
|
|
||||||
},
|
|
||||||
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
|
|
||||||
],
|
|
||||||
characters: [{ id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 }],
|
|
||||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
expect(result.current).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles mixed combatants: only leveled PCs and bestiary monsters contribute", () => {
|
|
||||||
// Party: one leveled PC, one without level (excluded)
|
|
||||||
// Monsters: one bestiary creature, one custom (excluded)
|
|
||||||
setup({
|
|
||||||
combatants: [
|
|
||||||
{
|
|
||||||
id: combatantId("c1"),
|
|
||||||
name: "Leveled",
|
|
||||||
playerCharacterId: pcId1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: combatantId("c2"),
|
|
||||||
name: "No Level",
|
|
||||||
playerCharacterId: pcId2,
|
|
||||||
},
|
|
||||||
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
|
|
||||||
{ id: combatantId("c4"), name: "Custom Monster" },
|
|
||||||
],
|
|
||||||
characters: [
|
|
||||||
{ id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 },
|
|
||||||
{ id: pcId2, name: "No Level", ac: 12, maxHp: 20 },
|
|
||||||
],
|
|
||||||
creatures: new Map([[crId1, { cr: "1" }]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
|
|
||||||
expect(result.current).not.toBeNull();
|
|
||||||
// 1 level-1 PC: budget low=50, mod=75, high=100
|
|
||||||
// 1 CR 1 monster: 200 XP → high (200 >= 100)
|
|
||||||
expect(result.current?.tier).toBe("high");
|
|
||||||
expect(result.current?.totalMonsterXp).toBe(200);
|
|
||||||
expect(result.current?.partyBudget.low).toBe(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes duplicate PC combatants in budget", () => {
|
|
||||||
// Same PC added twice → counts twice
|
|
||||||
setup({
|
|
||||||
combatants: [
|
|
||||||
{
|
|
||||||
id: combatantId("c1"),
|
|
||||||
name: "Hero 1",
|
|
||||||
playerCharacterId: pcId1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: combatantId("c2"),
|
|
||||||
name: "Hero 2",
|
|
||||||
playerCharacterId: pcId1,
|
|
||||||
},
|
|
||||||
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
|
|
||||||
],
|
|
||||||
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
|
|
||||||
creatures: new Map([[crId1, { cr: "1/4" }]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useDifficulty());
|
|
||||||
|
|
||||||
expect(result.current).not.toBeNull();
|
|
||||||
// 2x level 1: budget low=100
|
|
||||||
expect(result.current?.partyBudget.low).toBe(100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
427
apps/web/src/hooks/__tests__/use-difficulty.test.tsx
Normal file
427
apps/web/src/hooks/__tests__/use-difficulty.test.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import {
|
||||||
|
buildCombatant,
|
||||||
|
buildCreature,
|
||||||
|
buildEncounter,
|
||||||
|
} from "../../__tests__/factories/index.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import { useDifficulty } from "../use-difficulty.js";
|
||||||
|
import { useRulesEdition } from "../use-rules-edition.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pcId1 = playerCharacterId("pc-1");
|
||||||
|
const pcId2 = playerCharacterId("pc-2");
|
||||||
|
const crId1 = creatureId("srd:goblin");
|
||||||
|
|
||||||
|
const goblinCreature = buildCreature({
|
||||||
|
id: crId1,
|
||||||
|
name: "Goblin",
|
||||||
|
cr: "1/4",
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeWrapper(options: {
|
||||||
|
encounter: ReturnType<typeof buildEncounter>;
|
||||||
|
playerCharacters?: PlayerCharacter[];
|
||||||
|
creatures?: Map<CreatureId, Creature>;
|
||||||
|
}) {
|
||||||
|
const adapters = createTestAdapters({
|
||||||
|
encounter: options.encounter,
|
||||||
|
playerCharacters: options.playerCharacters ?? [],
|
||||||
|
creatures: options.creatures,
|
||||||
|
});
|
||||||
|
return ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useDifficulty", () => {
|
||||||
|
it("returns difficulty result for leveled PCs and bestiary monsters", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
expect(result.current?.tier).toBe(1);
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("returns null when data is insufficient (ED-2)", () => {
|
||||||
|
it("returns null when encounter has no combatants", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({ combatants: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when only custom combatants (no creatureId)", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Custom",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when bestiary monsters present but no PC combatants", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when PC combatants have no level", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when PC combatant references unknown player character", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId2,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed combatants: only leveled PCs and CR-bearing monsters contribute", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Leveled",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "No Level",
|
||||||
|
playerCharacterId: pcId2,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c3"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c4"),
|
||||||
|
name: "Custom Monster",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
{ id: pcId2, name: "No Level", ac: 12, maxHp: 20 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// 1 level-1 PC: budget low=50, mod=75, high=100
|
||||||
|
// CR 1/4 = 50 XP -> low (50 >= 50)
|
||||||
|
expect(result.current?.tier).toBe(1);
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(50);
|
||||||
|
expect(result.current?.thresholds[0].value).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes duplicate PC combatants in budget", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero 1",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Hero 2",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c3"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// 2x level 1: budget low=100
|
||||||
|
expect(result.current?.thresholds[0].value).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combatant toggled to party side subtracts XP", async () => {
|
||||||
|
const bugbear = buildCreature({
|
||||||
|
id: creatureId("srd:bugbear"),
|
||||||
|
name: "Bugbear",
|
||||||
|
cr: "1",
|
||||||
|
});
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Allied Guard",
|
||||||
|
creatureId: bugbear.id,
|
||||||
|
side: "party",
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c3"),
|
||||||
|
name: "Thug",
|
||||||
|
cr: "1",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[bugbear.id, bugbear]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// Thug CR 1 = 200 XP, Allied Guard CR 1 = 200 XP subtracted, net = 0
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(0);
|
||||||
|
expect(result.current?.tier).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("default side resolution: PC -> party, non-PC -> enemy", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 3 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// Level 3 budget: low=150, mod=225, high=400
|
||||||
|
// CR 1/4 = 50 XP -> trivial
|
||||||
|
expect(result.current?.thresholds[0].value).toBe(150);
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(50);
|
||||||
|
expect(result.current?.tier).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 2014 difficulty when edition is 5e", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set edition via the hook's external store
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
editionResult.current.setEdition("5e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// 2014: 4 thresholds with Easy/Medium/Hard/Deadly labels
|
||||||
|
expect(result.current?.thresholds).toHaveLength(4);
|
||||||
|
expect(result.current?.thresholds[0].label).toBe("Easy");
|
||||||
|
// CR 1/4 = 50 XP, 1 PC (<3) shifts x1 → x1.5, adjusted = 75
|
||||||
|
expect(result.current?.encounterMultiplier).toBe(1.5);
|
||||||
|
expect(result.current?.adjustedXp).toBe(75);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("custom combatant with CR on party side subtracts XP", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Ally",
|
||||||
|
cr: "2",
|
||||||
|
side: "party",
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c3"),
|
||||||
|
name: "Goblin",
|
||||||
|
creatureId: crId1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[crId1, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// CR 1/4 = 50 XP enemy, CR 2 = 450 XP ally subtracted, net = 0 (floored)
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
238
apps/web/src/hooks/__tests__/use-encounter-adjustment.test.ts
Normal file
238
apps/web/src/hooks/__tests__/use-encounter-adjustment.test.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import type { Pf2eCreature } from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
combatantId,
|
||||||
|
creatureId,
|
||||||
|
EMPTY_UNDO_REDO_STATE,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
||||||
|
|
||||||
|
const BASE_CREATURE: Pf2eCreature = {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("b1:goblin-warrior"),
|
||||||
|
name: "Goblin Warrior",
|
||||||
|
source: "B1",
|
||||||
|
sourceDisplayName: "Bestiary",
|
||||||
|
level: 5,
|
||||||
|
traits: ["humanoid"],
|
||||||
|
perception: 12,
|
||||||
|
abilityMods: { str: 4, dex: 2, con: 3, int: 0, wis: 1, cha: -1 },
|
||||||
|
ac: 22,
|
||||||
|
saveFort: 14,
|
||||||
|
saveRef: 11,
|
||||||
|
saveWill: 9,
|
||||||
|
hp: 75,
|
||||||
|
speed: "25 feet",
|
||||||
|
};
|
||||||
|
|
||||||
|
function stateWithCreature(
|
||||||
|
name: string,
|
||||||
|
hp: number,
|
||||||
|
ac: number,
|
||||||
|
adj?: "weak" | "elite",
|
||||||
|
): EncounterState {
|
||||||
|
return {
|
||||||
|
encounter: {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name,
|
||||||
|
maxHp: hp,
|
||||||
|
currentHp: hp,
|
||||||
|
ac,
|
||||||
|
creatureId: creatureId("b1:goblin-warrior"),
|
||||||
|
...(adj !== undefined && { creatureAdjustment: adj }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
undoRedoState: EMPTY_UNDO_REDO_STATE,
|
||||||
|
events: [],
|
||||||
|
nextId: 1,
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("set-creature-adjustment", () => {
|
||||||
|
it("Normal → Elite: HP increases, AC +2, name prefixed, adjustment stored", () => {
|
||||||
|
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "elite",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(95); // 75 + 20 (level 5 bracket)
|
||||||
|
expect(c.currentHp).toBe(95);
|
||||||
|
expect(c.ac).toBe(24);
|
||||||
|
expect(c.name).toBe("Elite Goblin Warrior");
|
||||||
|
expect(c.creatureAdjustment).toBe("elite");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Normal → Weak: HP decreases, AC −2, name prefixed", () => {
|
||||||
|
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "weak",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(55); // 75 - 20
|
||||||
|
expect(c.currentHp).toBe(55);
|
||||||
|
expect(c.ac).toBe(20);
|
||||||
|
expect(c.name).toBe("Weak Goblin Warrior");
|
||||||
|
expect(c.creatureAdjustment).toBe("weak");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Elite → Normal: HP/AC/name revert", () => {
|
||||||
|
const state = stateWithCreature("Elite Goblin Warrior", 95, 24, "elite");
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: undefined,
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(75);
|
||||||
|
expect(c.currentHp).toBe(75);
|
||||||
|
expect(c.ac).toBe(22);
|
||||||
|
expect(c.name).toBe("Goblin Warrior");
|
||||||
|
expect(c.creatureAdjustment).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Elite → Weak: full swing applied in one step", () => {
|
||||||
|
const state = stateWithCreature("Elite Goblin Warrior", 95, 24, "elite");
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "weak",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(55); // 95 - 40 (revert +20, apply -20)
|
||||||
|
expect(c.currentHp).toBe(55);
|
||||||
|
expect(c.ac).toBe(20); // 24 - 4
|
||||||
|
expect(c.name).toBe("Weak Goblin Warrior");
|
||||||
|
expect(c.creatureAdjustment).toBe("weak");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggle with damage taken: currentHp shifted by delta, clamped to 0", () => {
|
||||||
|
const state: EncounterState = {
|
||||||
|
...stateWithCreature("Goblin Warrior", 75, 22),
|
||||||
|
};
|
||||||
|
// Simulate damage: currentHp = 10
|
||||||
|
const damaged: EncounterState = {
|
||||||
|
...state,
|
||||||
|
encounter: {
|
||||||
|
...state.encounter,
|
||||||
|
combatants: [{ ...state.encounter.combatants[0], currentHp: 10 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = encounterReducer(damaged, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "weak",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(55);
|
||||||
|
// currentHp = 10 - 20 = -10, clamped to 0
|
||||||
|
expect(c.currentHp).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggle with temp HP: temp HP unchanged", () => {
|
||||||
|
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||||
|
const withTemp: EncounterState = {
|
||||||
|
...state,
|
||||||
|
encounter: {
|
||||||
|
...state.encounter,
|
||||||
|
combatants: [{ ...state.encounter.combatants[0], tempHp: 10 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = encounterReducer(withTemp, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "elite",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].tempHp).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("name with auto-number suffix: 'Goblin 2' → 'Elite Goblin 2'", () => {
|
||||||
|
const state = stateWithCreature("Goblin 2", 75, 22);
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "elite",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].name).toBe("Elite Goblin 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manually renamed combatant: prefix not found, name unchanged", () => {
|
||||||
|
// Combatant was elite but manually renamed to "Big Boss"
|
||||||
|
const state = stateWithCreature("Big Boss", 95, 24, "elite");
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: undefined,
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
// No "Elite " prefix found, so name stays as is
|
||||||
|
expect(next.encounter.combatants[0].name).toBe("Big Boss");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits CreatureAdjustmentSet event", () => {
|
||||||
|
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "elite",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = next.events.find((e) => e.type === "CreatureAdjustmentSet");
|
||||||
|
expect(event).toEqual({
|
||||||
|
type: "CreatureAdjustmentSet",
|
||||||
|
combatantId: "c-1",
|
||||||
|
adjustment: "elite",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unchanged state when adjustment is the same", () => {
|
||||||
|
const state = stateWithCreature("Elite Goblin Warrior", 95, 24, "elite");
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "elite",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unchanged state for unknown combatant", () => {
|
||||||
|
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-99"),
|
||||||
|
adjustment: "elite",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import type { BestiaryIndexEntry, PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import type { SearchResult } from "../use-bestiary.js";
|
||||||
import { useEncounter } from "../use-encounter.js";
|
import { useEncounter } from "../use-encounter.js";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -152,9 +153,11 @@ describe("useEncounter", () => {
|
|||||||
expect(result.current.canRollAllInitiative).toBe(false);
|
expect(result.current.canRollAllInitiative).toBe(false);
|
||||||
|
|
||||||
// Add from bestiary to get a creature combatant
|
// Add from bestiary to get a creature combatant
|
||||||
const entry: BestiaryIndexEntry = {
|
const entry: SearchResult = {
|
||||||
|
system: "dnd",
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
source: "MM",
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
ac: 15,
|
ac: 15,
|
||||||
hp: 7,
|
hp: 7,
|
||||||
dex: 14,
|
dex: 14,
|
||||||
@@ -175,9 +178,11 @@ describe("useEncounter", () => {
|
|||||||
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
|
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
|
||||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
const entry: BestiaryIndexEntry = {
|
const entry: SearchResult = {
|
||||||
|
system: "dnd",
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
source: "MM",
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
ac: 15,
|
ac: 15,
|
||||||
hp: 7,
|
hp: 7,
|
||||||
dex: 14,
|
dex: 14,
|
||||||
@@ -202,9 +207,11 @@ describe("useEncounter", () => {
|
|||||||
it("addFromBestiary auto-numbers duplicate names", () => {
|
it("addFromBestiary auto-numbers duplicate names", () => {
|
||||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
const entry: BestiaryIndexEntry = {
|
const entry: SearchResult = {
|
||||||
|
system: "dnd",
|
||||||
name: "Goblin",
|
name: "Goblin",
|
||||||
source: "MM",
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
ac: 15,
|
ac: 15,
|
||||||
hp: 7,
|
hp: 7,
|
||||||
dex: 14,
|
dex: 14,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useRulesEdition } from "../use-rules-edition.js";
|
import { useRulesEdition } from "../use-rules-edition.js";
|
||||||
|
|
||||||
const STORAGE_KEY = "initiative:rules-edition";
|
const STORAGE_KEY = "initiative:game-system";
|
||||||
|
const OLD_STORAGE_KEY = "initiative:rules-edition";
|
||||||
|
|
||||||
describe("useRulesEdition", () => {
|
describe("useRulesEdition", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -11,6 +12,7 @@ describe("useRulesEdition", () => {
|
|||||||
const { result } = renderHook(() => useRulesEdition());
|
const { result } = renderHook(() => useRulesEdition());
|
||||||
act(() => result.current.setEdition("5.5e"));
|
act(() => result.current.setEdition("5.5e"));
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
localStorage.removeItem(OLD_STORAGE_KEY);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to 5.5e", () => {
|
it("defaults to 5.5e", () => {
|
||||||
@@ -42,4 +44,31 @@ describe("useRulesEdition", () => {
|
|||||||
|
|
||||||
expect(r2.current.edition).toBe("5e");
|
expect(r2.current.edition).toBe("5e");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts pf2e as a valid game system", () => {
|
||||||
|
const { result } = renderHook(() => useRulesEdition());
|
||||||
|
|
||||||
|
act(() => result.current.setEdition("pf2e"));
|
||||||
|
|
||||||
|
expect(result.current.edition).toBe("pf2e");
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe("pf2e");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates from old storage key on fresh module load", async () => {
|
||||||
|
// Set up old key before re-importing the module
|
||||||
|
localStorage.setItem(OLD_STORAGE_KEY, "5e");
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
// Force a fresh module so loadEdition() re-runs at init time
|
||||||
|
vi.resetModules();
|
||||||
|
const { useRulesEdition: freshHook } = await import(
|
||||||
|
"../use-rules-edition.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => freshHook());
|
||||||
|
|
||||||
|
expect(result.current.edition).toBe("5e");
|
||||||
|
expect(localStorage.getItem(STORAGE_KEY)).toBe("5e");
|
||||||
|
expect(localStorage.getItem(OLD_STORAGE_KEY)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export function useAutoStatBlock(): void {
|
|||||||
const { encounter } = useEncounterContext();
|
const { encounter } = useEncounterContext();
|
||||||
const { panelView, updateCreature } = useSidePanelContext();
|
const { panelView, updateCreature } = useSidePanelContext();
|
||||||
|
|
||||||
const activeCreatureId =
|
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||||
encounter.combatants[encounter.activeIndex]?.creatureId;
|
const activeCreatureId = activeCombatant?.creatureId;
|
||||||
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -21,7 +21,13 @@ export function useAutoStatBlock(): void {
|
|||||||
activeCreatureId &&
|
activeCreatureId &&
|
||||||
panelView.mode === "creature"
|
panelView.mode === "creature"
|
||||||
) {
|
) {
|
||||||
updateCreature(activeCreatureId);
|
updateCreature(activeCreatureId, activeCombatant.id);
|
||||||
}
|
}
|
||||||
}, [encounter.activeIndex, activeCreatureId, panelView.mode, updateCreature]);
|
}, [
|
||||||
|
encounter.activeIndex,
|
||||||
|
activeCreatureId,
|
||||||
|
activeCombatant?.id,
|
||||||
|
panelView.mode,
|
||||||
|
updateCreature,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,37 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AnyCreature,
|
||||||
BestiaryIndexEntry,
|
BestiaryIndexEntry,
|
||||||
Creature,
|
|
||||||
CreatureId,
|
CreatureId,
|
||||||
|
Pf2eBestiaryIndexEntry,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
normalizeBestiary,
|
normalizeBestiary,
|
||||||
setSourceDisplayNames,
|
setSourceDisplayNames,
|
||||||
} from "../adapters/bestiary-adapter.js";
|
} from "../adapters/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";
|
||||||
|
|
||||||
export interface SearchResult extends BestiaryIndexEntry {
|
export type SearchResult =
|
||||||
|
| (BestiaryIndexEntry & {
|
||||||
|
readonly system: "dnd";
|
||||||
readonly sourceDisplayName: string;
|
readonly sourceDisplayName: string;
|
||||||
}
|
})
|
||||||
|
| (Pf2eBestiaryIndexEntry & {
|
||||||
|
readonly system: "pf2e";
|
||||||
|
readonly sourceDisplayName: string;
|
||||||
|
});
|
||||||
|
|
||||||
interface BestiaryHook {
|
interface BestiaryHook {
|
||||||
search: (query: string) => SearchResult[];
|
search: (query: string) => SearchResult[];
|
||||||
getCreature: (id: CreatureId) => Creature | undefined;
|
getCreature: (id: CreatureId) => AnyCreature | undefined;
|
||||||
isLoaded: boolean;
|
isLoaded: boolean;
|
||||||
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
fetchAndCacheSource: (
|
||||||
|
sourceCode: string,
|
||||||
|
url: string,
|
||||||
|
) => Promise<{ skippedNames: string[] }>;
|
||||||
uploadAndCacheSource: (
|
uploadAndCacheSource: (
|
||||||
sourceCode: string,
|
sourceCode: string,
|
||||||
jsonData: unknown,
|
jsonData: unknown,
|
||||||
@@ -27,29 +39,149 @@ 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 } = useAdapters();
|
const { bestiaryCache, bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [creatureMap, setCreatureMap] = useState(
|
const [creatureMap, setCreatureMap] = useState(
|
||||||
() => new Map<CreatureId, Creature>(),
|
() => new Map<CreatureId, AnyCreature>(),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const index = bestiaryIndex.loadIndex();
|
const index = bestiaryIndex.loadIndex();
|
||||||
setSourceDisplayNames(index.sources as Record<string, string>);
|
setSourceDisplayNames(index.sources as Record<string, string>);
|
||||||
if (index.creatures.length > 0) {
|
|
||||||
|
const pf2eIndex = pf2eBestiaryIndex.loadIndex();
|
||||||
|
|
||||||
|
if (index.creatures.length > 0 || pf2eIndex.creatures.length > 0) {
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||||
setCreatureMap(map);
|
setCreatureMap(map);
|
||||||
});
|
});
|
||||||
}, [bestiaryCache, bestiaryIndex]);
|
}, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
|
||||||
|
|
||||||
const search = useCallback(
|
const search = useCallback(
|
||||||
(query: string): SearchResult[] => {
|
(query: string): SearchResult[] => {
|
||||||
if (query.length < 2) return [];
|
if (query.length < 2) return [];
|
||||||
const lower = query.toLowerCase();
|
const lower = query.toLowerCase();
|
||||||
|
|
||||||
|
if (edition === "pf2e") {
|
||||||
|
const index = pf2eBestiaryIndex.loadIndex();
|
||||||
|
return index.creatures
|
||||||
|
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((c) => ({
|
||||||
|
...c,
|
||||||
|
system: "pf2e" as const,
|
||||||
|
sourceDisplayName: pf2eBestiaryIndex.getSourceDisplayName(c.source),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const index = bestiaryIndex.loadIndex();
|
const index = bestiaryIndex.loadIndex();
|
||||||
return index.creatures
|
return index.creatures
|
||||||
.filter((c) => c.name.toLowerCase().includes(lower))
|
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||||
@@ -57,28 +189,50 @@ export function useBestiary(): BestiaryHook {
|
|||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
|
system: "dnd" as const,
|
||||||
sourceDisplayName: bestiaryIndex.getSourceDisplayName(c.source),
|
sourceDisplayName: bestiaryIndex.getSourceDisplayName(c.source),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
[bestiaryIndex],
|
[bestiaryIndex, pf2eBestiaryIndex, edition],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getCreature = useCallback(
|
const getCreature = useCallback(
|
||||||
(id: CreatureId): Creature | undefined => {
|
(id: CreatureId): AnyCreature | undefined => {
|
||||||
return creatureMap.get(id);
|
return creatureMap.get(id);
|
||||||
},
|
},
|
||||||
[creatureMap],
|
[creatureMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const system = edition === "pf2e" ? "pf2e" : "dnd";
|
||||||
|
|
||||||
const isSourceCachedFn = useCallback(
|
const isSourceCachedFn = useCallback(
|
||||||
(sourceCode: string): Promise<boolean> => {
|
(sourceCode: string): Promise<boolean> => {
|
||||||
return bestiaryCache.isSourceCached(sourceCode);
|
return bestiaryCache.isSourceCached(system, sourceCode);
|
||||||
},
|
},
|
||||||
[bestiaryCache],
|
[bestiaryCache, system],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchAndCacheSource = useCallback(
|
const fetchAndCacheSource = useCallback(
|
||||||
async (sourceCode: string, url: string): Promise<void> => {
|
async (
|
||||||
|
sourceCode: string,
|
||||||
|
url: string,
|
||||||
|
): Promise<{ skippedNames: string[] }> => {
|
||||||
|
let creatures: AnyCreature[];
|
||||||
|
let skippedNames: string[] = [];
|
||||||
|
|
||||||
|
if (edition === "pf2e") {
|
||||||
|
const paths = pf2eBestiaryIndex.getCreaturePathsForSource(sourceCode);
|
||||||
|
const displayName = pf2eBestiaryIndex.getSourceDisplayName(sourceCode);
|
||||||
|
const result = await fetchPf2eSource(
|
||||||
|
paths,
|
||||||
|
url,
|
||||||
|
sourceCode,
|
||||||
|
displayName,
|
||||||
|
pf2eBestiaryIndex.getCreatureNamesByPaths,
|
||||||
|
);
|
||||||
|
creatures = result.creatures;
|
||||||
|
skippedNames = result.skippedNames;
|
||||||
|
} else {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -86,9 +240,19 @@ export function useBestiary(): BestiaryHook {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
const creatures = normalizeBestiary(json);
|
creatures = normalizeBestiary(json);
|
||||||
const displayName = bestiaryIndex.getSourceDisplayName(sourceCode);
|
}
|
||||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
|
||||||
|
const displayName =
|
||||||
|
edition === "pf2e"
|
||||||
|
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
|
||||||
|
: bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||||
|
await bestiaryCache.cacheSource(
|
||||||
|
system,
|
||||||
|
sourceCode,
|
||||||
|
displayName,
|
||||||
|
creatures,
|
||||||
|
);
|
||||||
setCreatureMap((prev) => {
|
setCreatureMap((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
for (const c of creatures) {
|
for (const c of creatures) {
|
||||||
@@ -96,16 +260,33 @@ export function useBestiary(): BestiaryHook {
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
return { skippedNames };
|
||||||
},
|
},
|
||||||
[bestiaryCache, bestiaryIndex],
|
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadAndCacheSource = useCallback(
|
const uploadAndCacheSource = useCallback(
|
||||||
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies
|
const creatures =
|
||||||
const creatures = normalizeBestiary(jsonData as any);
|
edition === "pf2e"
|
||||||
const displayName = bestiaryIndex.getSourceDisplayName(sourceCode);
|
? normalizeFoundryCreatures(
|
||||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
Array.isArray(jsonData) ? jsonData : [jsonData],
|
||||||
|
sourceCode,
|
||||||
|
pf2eBestiaryIndex.getSourceDisplayName(sourceCode),
|
||||||
|
)
|
||||||
|
: normalizeBestiary(
|
||||||
|
jsonData as Parameters<typeof normalizeBestiary>[0],
|
||||||
|
);
|
||||||
|
const displayName =
|
||||||
|
edition === "pf2e"
|
||||||
|
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
|
||||||
|
: bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||||
|
await bestiaryCache.cacheSource(
|
||||||
|
system,
|
||||||
|
sourceCode,
|
||||||
|
displayName,
|
||||||
|
creatures,
|
||||||
|
);
|
||||||
setCreatureMap((prev) => {
|
setCreatureMap((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
for (const c of creatures) {
|
for (const c of creatures) {
|
||||||
@@ -114,7 +295,7 @@ export function useBestiary(): BestiaryHook {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[bestiaryCache, bestiaryIndex],
|
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshCache = useCallback(async (): Promise<void> => {
|
const refreshCache = useCallback(async (): Promise<void> => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useAdapters } from "../contexts/adapter-context.js";
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
|
||||||
const BATCH_SIZE = 6;
|
const BATCH_SIZE = 6;
|
||||||
|
|
||||||
@@ -21,7 +22,10 @@ interface BulkImportHook {
|
|||||||
state: BulkImportState;
|
state: BulkImportState;
|
||||||
startImport: (
|
startImport: (
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
fetchAndCacheSource: (
|
||||||
|
sourceCode: string,
|
||||||
|
url: string,
|
||||||
|
) => Promise<{ skippedNames: string[] }>,
|
||||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||||
refreshCache: () => Promise<void>,
|
refreshCache: () => Promise<void>,
|
||||||
) => void;
|
) => void;
|
||||||
@@ -29,18 +33,23 @@ interface BulkImportHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBulkImport(): BulkImportHook {
|
export function useBulkImport(): BulkImportHook {
|
||||||
const { bestiaryIndex } = useAdapters();
|
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
||||||
const [state, setState] = useState<BulkImportState>(IDLE_STATE);
|
const [state, setState] = useState<BulkImportState>(IDLE_STATE);
|
||||||
const countersRef = useRef({ completed: 0, failed: 0 });
|
const countersRef = useRef({ completed: 0, failed: 0 });
|
||||||
|
|
||||||
const startImport = useCallback(
|
const startImport = useCallback(
|
||||||
(
|
(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
fetchAndCacheSource: (
|
||||||
|
sourceCode: string,
|
||||||
|
url: string,
|
||||||
|
) => Promise<{ skippedNames: string[] }>,
|
||||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||||
refreshCache: () => Promise<void>,
|
refreshCache: () => Promise<void>,
|
||||||
) => {
|
) => {
|
||||||
const allCodes = bestiaryIndex.getAllSourceCodes();
|
const allCodes = indexPort.getAllSourceCodes();
|
||||||
const total = allCodes.length;
|
const total = allCodes.length;
|
||||||
|
|
||||||
countersRef.current = { completed: 0, failed: 0 };
|
countersRef.current = { completed: 0, failed: 0 };
|
||||||
@@ -81,7 +90,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
chain.then(() =>
|
chain.then(() =>
|
||||||
Promise.allSettled(
|
Promise.allSettled(
|
||||||
batch.map(async ({ code }) => {
|
batch.map(async ({ code }) => {
|
||||||
const url = bestiaryIndex.getDefaultFetchUrl(code, baseUrl);
|
const url = indexPort.getDefaultFetchUrl(code, baseUrl);
|
||||||
try {
|
try {
|
||||||
await fetchAndCacheSource(code, url);
|
await fetchAndCacheSource(code, url);
|
||||||
countersRef.current.completed++;
|
countersRef.current.completed++;
|
||||||
@@ -115,7 +124,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
[bestiaryIndex],
|
[indexPort],
|
||||||
);
|
);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
Combatant,
|
Combatant,
|
||||||
CreatureId,
|
CreatureId,
|
||||||
|
DifficultyThreshold,
|
||||||
DifficultyTier,
|
DifficultyTier,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
@@ -9,6 +10,8 @@ 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";
|
||||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
import { resolveSide } from "./use-difficulty.js";
|
||||||
|
|
||||||
export interface BreakdownCombatant {
|
export interface BreakdownCombatant {
|
||||||
readonly combatant: Combatant;
|
readonly combatant: Combatant;
|
||||||
@@ -16,125 +19,153 @@ export interface BreakdownCombatant {
|
|||||||
readonly xp: number | null;
|
readonly xp: number | null;
|
||||||
readonly source: string | null;
|
readonly source: string | null;
|
||||||
readonly editable: boolean;
|
readonly editable: boolean;
|
||||||
|
readonly side: "party" | "enemy";
|
||||||
|
readonly level: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DifficultyBreakdown {
|
interface DifficultyBreakdown {
|
||||||
readonly tier: DifficultyTier;
|
readonly tier: DifficultyTier;
|
||||||
readonly totalMonsterXp: number;
|
readonly totalMonsterXp: number;
|
||||||
readonly partyBudget: {
|
readonly thresholds: readonly DifficultyThreshold[];
|
||||||
readonly low: number;
|
readonly encounterMultiplier: number | undefined;
|
||||||
readonly moderate: number;
|
readonly adjustedXp: number | undefined;
|
||||||
readonly high: number;
|
readonly partySizeAdjusted: boolean | undefined;
|
||||||
};
|
|
||||||
readonly pcCount: number;
|
readonly pcCount: number;
|
||||||
readonly combatants: readonly BreakdownCombatant[];
|
readonly partyCombatants: readonly BreakdownCombatant[];
|
||||||
|
readonly enemyCombatants: readonly BreakdownCombatant[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||||
const { encounter } = useEncounterContext();
|
const { encounter } = useEncounterContext();
|
||||||
const { characters } = usePlayerCharactersContext();
|
const { characters } = usePlayerCharactersContext();
|
||||||
const { getCreature } = useBestiaryContext();
|
const { getCreature } = useBestiaryContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const partyLevels = derivePartyLevels(encounter.combatants, characters);
|
const { partyCombatants, enemyCombatants, descriptors, pcCount } =
|
||||||
const { entries, crs } = classifyCombatants(
|
classifyCombatants(encounter.combatants, characters, getCreature);
|
||||||
encounter.combatants,
|
|
||||||
getCreature,
|
const hasPartyLevel = descriptors.some(
|
||||||
|
(d) => d.side === "party" && d.level !== undefined,
|
||||||
);
|
);
|
||||||
|
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||||
|
|
||||||
if (partyLevels.length === 0 || crs.length === 0) {
|
if (!hasPartyLevel || !hasCr) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = calculateEncounterDifficulty(partyLevels, crs);
|
const result = calculateEncounterDifficulty(descriptors, edition);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
pcCount: partyLevels.length,
|
pcCount,
|
||||||
combatants: entries,
|
partyCombatants,
|
||||||
|
enemyCombatants,
|
||||||
};
|
};
|
||||||
}, [encounter.combatants, characters, getCreature]);
|
}, [encounter.combatants, characters, getCreature, edition]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyBestiaryCombatant(
|
type CreatureInfo = {
|
||||||
|
cr?: string;
|
||||||
|
source: string;
|
||||||
|
sourceDisplayName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildBreakdownEntry(
|
||||||
c: Combatant,
|
c: Combatant,
|
||||||
getCreature: (
|
side: "party" | "enemy",
|
||||||
id: CreatureId,
|
level: number | undefined,
|
||||||
) => { cr: string; source: string; sourceDisplayName: string } | undefined,
|
creature: CreatureInfo | undefined,
|
||||||
): { entry: BreakdownCombatant; cr: string | null } {
|
): BreakdownCombatant {
|
||||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
if (c.playerCharacterId) {
|
||||||
if (creature) {
|
|
||||||
return {
|
return {
|
||||||
entry: {
|
|
||||||
combatant: c,
|
|
||||||
cr: creature.cr,
|
|
||||||
xp: crToXp(creature.cr),
|
|
||||||
source: creature.sourceDisplayName ?? creature.source,
|
|
||||||
editable: false,
|
|
||||||
},
|
|
||||||
cr: creature.cr,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
entry: {
|
|
||||||
combatant: c,
|
combatant: c,
|
||||||
cr: null,
|
cr: null,
|
||||||
xp: null,
|
xp: null,
|
||||||
source: null,
|
source: null,
|
||||||
editable: false,
|
editable: false,
|
||||||
},
|
side,
|
||||||
cr: null,
|
level,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (creature) {
|
||||||
function classifyCombatants(
|
const cr = creature.cr ?? null;
|
||||||
combatants: readonly Combatant[],
|
return {
|
||||||
getCreature: (
|
combatant: c,
|
||||||
id: CreatureId,
|
cr,
|
||||||
) => { cr: string; source: string; sourceDisplayName: string } | undefined,
|
xp: cr ? crToXp(cr) : null,
|
||||||
): { entries: BreakdownCombatant[]; crs: string[] } {
|
source: creature.sourceDisplayName ?? creature.source,
|
||||||
const entries: BreakdownCombatant[] = [];
|
editable: false,
|
||||||
const crs: string[] = [];
|
side,
|
||||||
|
level: undefined,
|
||||||
for (const c of combatants) {
|
};
|
||||||
if (c.playerCharacterId) continue;
|
}
|
||||||
|
if (c.cr) {
|
||||||
if (c.creatureId) {
|
return {
|
||||||
const { entry, cr } = classifyBestiaryCombatant(c, getCreature);
|
|
||||||
entries.push(entry);
|
|
||||||
if (cr) crs.push(cr);
|
|
||||||
} else if (c.cr) {
|
|
||||||
crs.push(c.cr);
|
|
||||||
entries.push({
|
|
||||||
combatant: c,
|
combatant: c,
|
||||||
cr: c.cr,
|
cr: c.cr,
|
||||||
xp: crToXp(c.cr),
|
xp: crToXp(c.cr),
|
||||||
source: null,
|
source: null,
|
||||||
editable: true,
|
editable: true,
|
||||||
});
|
side,
|
||||||
} else {
|
level: undefined,
|
||||||
entries.push({
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
combatant: c,
|
combatant: c,
|
||||||
cr: null,
|
cr: null,
|
||||||
xp: null,
|
xp: null,
|
||||||
source: null,
|
source: null,
|
||||||
editable: true,
|
editable: !c.creatureId,
|
||||||
});
|
side,
|
||||||
}
|
level: undefined,
|
||||||
}
|
};
|
||||||
|
|
||||||
return { entries, crs };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function derivePartyLevels(
|
function resolveLevel(
|
||||||
|
c: Combatant,
|
||||||
|
characters: readonly PlayerCharacter[],
|
||||||
|
): number | undefined {
|
||||||
|
if (!c.playerCharacterId) return undefined;
|
||||||
|
return characters.find((p) => p.id === c.playerCharacterId)?.level;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCr(
|
||||||
|
c: Combatant,
|
||||||
|
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||||
|
): { cr: string | null; creature: CreatureInfo | undefined } {
|
||||||
|
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||||
|
const cr = creature?.cr ?? c.cr ?? null;
|
||||||
|
return { cr, creature };
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyCombatants(
|
||||||
combatants: readonly Combatant[],
|
combatants: readonly Combatant[],
|
||||||
characters: readonly PlayerCharacter[],
|
characters: readonly PlayerCharacter[],
|
||||||
): number[] {
|
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||||
const levels: number[] = [];
|
) {
|
||||||
|
const partyCombatants: BreakdownCombatant[] = [];
|
||||||
|
const enemyCombatants: BreakdownCombatant[] = [];
|
||||||
|
const descriptors: {
|
||||||
|
level?: number;
|
||||||
|
cr?: string;
|
||||||
|
side: "party" | "enemy";
|
||||||
|
}[] = [];
|
||||||
|
let pcCount = 0;
|
||||||
|
|
||||||
for (const c of combatants) {
|
for (const c of combatants) {
|
||||||
if (!c.playerCharacterId) continue;
|
const side = resolveSide(c);
|
||||||
const pc = characters.find((p) => p.id === c.playerCharacterId);
|
const level = resolveLevel(c, characters);
|
||||||
if (pc?.level !== undefined) levels.push(pc.level);
|
if (level !== undefined) pcCount++;
|
||||||
|
|
||||||
|
const { cr, creature } = resolveCr(c, getCreature);
|
||||||
|
|
||||||
|
if (level !== undefined || cr != null) {
|
||||||
|
descriptors.push({ level, cr: cr ?? undefined, side });
|
||||||
}
|
}
|
||||||
return levels;
|
|
||||||
|
const entry = buildBreakdownEntry(c, side, level, creature);
|
||||||
|
const target = side === "party" ? partyCombatants : enemyCombatants;
|
||||||
|
target.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { partyCombatants, enemyCombatants, descriptors, pcCount };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AnyCreature,
|
||||||
Combatant,
|
Combatant,
|
||||||
|
CombatantDescriptor,
|
||||||
CreatureId,
|
CreatureId,
|
||||||
DifficultyResult,
|
DifficultyResult,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
@@ -9,49 +11,58 @@ 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";
|
||||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
|
||||||
function derivePartyLevels(
|
export function resolveSide(c: Combatant): "party" | "enemy" {
|
||||||
combatants: readonly Combatant[],
|
if (c.side) return c.side;
|
||||||
characters: readonly PlayerCharacter[],
|
return c.playerCharacterId ? "party" : "enemy";
|
||||||
): number[] {
|
|
||||||
const levels: number[] = [];
|
|
||||||
for (const c of combatants) {
|
|
||||||
if (!c.playerCharacterId) continue;
|
|
||||||
const pc = characters.find((p) => p.id === c.playerCharacterId);
|
|
||||||
if (pc?.level !== undefined) levels.push(pc.level);
|
|
||||||
}
|
|
||||||
return levels;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveMonsterCrs(
|
function buildDescriptors(
|
||||||
combatants: readonly Combatant[],
|
combatants: readonly Combatant[],
|
||||||
getCreature: (id: CreatureId) => { cr: string } | undefined,
|
characters: readonly PlayerCharacter[],
|
||||||
): string[] {
|
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||||
const crs: string[] = [];
|
): CombatantDescriptor[] {
|
||||||
|
const descriptors: CombatantDescriptor[] = [];
|
||||||
for (const c of combatants) {
|
for (const c of combatants) {
|
||||||
if (c.creatureId) {
|
const side = resolveSide(c);
|
||||||
const creature = getCreature(c.creatureId);
|
const level = c.playerCharacterId
|
||||||
if (creature) crs.push(creature.cr);
|
? characters.find((p) => p.id === c.playerCharacterId)?.level
|
||||||
} else if (c.cr) {
|
: undefined;
|
||||||
crs.push(c.cr);
|
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||||
|
const creatureCr =
|
||||||
|
creature && !("system" in creature) ? creature.cr : undefined;
|
||||||
|
const cr = creatureCr ?? c.cr ?? undefined;
|
||||||
|
|
||||||
|
if (level !== undefined || cr !== undefined) {
|
||||||
|
descriptors.push({ level, cr, side });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return crs;
|
return descriptors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDifficulty(): DifficultyResult | null {
|
export function useDifficulty(): DifficultyResult | null {
|
||||||
const { encounter } = useEncounterContext();
|
const { encounter } = useEncounterContext();
|
||||||
const { characters } = usePlayerCharactersContext();
|
const { characters } = usePlayerCharactersContext();
|
||||||
const { getCreature } = useBestiaryContext();
|
const { getCreature } = useBestiaryContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const partyLevels = derivePartyLevels(encounter.combatants, characters);
|
if (edition === "pf2e") return null;
|
||||||
const monsterCrs = deriveMonsterCrs(encounter.combatants, getCreature);
|
|
||||||
|
|
||||||
if (partyLevels.length === 0 || monsterCrs.length === 0) {
|
const descriptors = buildDescriptors(
|
||||||
return null;
|
encounter.combatants,
|
||||||
}
|
characters,
|
||||||
|
getCreature,
|
||||||
|
);
|
||||||
|
|
||||||
return calculateEncounterDifficulty(partyLevels, monsterCrs);
|
const hasPartyLevel = descriptors.some(
|
||||||
}, [encounter.combatants, characters, getCreature]);
|
(d) => d.side === "party" && d.level !== undefined,
|
||||||
|
);
|
||||||
|
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||||
|
|
||||||
|
if (!hasPartyLevel || !hasCr) return null;
|
||||||
|
|
||||||
|
return calculateEncounterDifficulty(descriptors, edition);
|
||||||
|
}, [encounter.combatants, characters, getCreature, edition]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
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,
|
||||||
|
decrementConditionUseCase,
|
||||||
editCombatantUseCase,
|
editCombatantUseCase,
|
||||||
redoUseCase,
|
redoUseCase,
|
||||||
removeCombatantUseCase,
|
removeCombatantUseCase,
|
||||||
|
removePersistentDamageUseCase,
|
||||||
retreatTurnUseCase,
|
retreatTurnUseCase,
|
||||||
setAcUseCase,
|
setAcUseCase,
|
||||||
|
setConditionValueUseCase,
|
||||||
setCrUseCase,
|
setCrUseCase,
|
||||||
setHpUseCase,
|
setHpUseCase,
|
||||||
setInitiativeUseCase,
|
setInitiativeUseCase,
|
||||||
|
setSideUseCase,
|
||||||
setTempHpUseCase,
|
setTempHpUseCase,
|
||||||
toggleConcentrationUseCase,
|
toggleConcentrationUseCase,
|
||||||
toggleConditionUseCase,
|
toggleConditionUseCase,
|
||||||
undoUseCase,
|
undoUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
import type {
|
import type {
|
||||||
BestiaryIndexEntry,
|
|
||||||
CombatantId,
|
CombatantId,
|
||||||
CombatantInit,
|
CombatantInit,
|
||||||
ConditionId,
|
ConditionId,
|
||||||
@@ -26,12 +30,16 @@ import type {
|
|||||||
DomainError,
|
DomainError,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
Encounter,
|
Encounter,
|
||||||
|
PersistentDamageType,
|
||||||
|
Pf2eCreature,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
UndoRedoState,
|
UndoRedoState,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
|
acDelta,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
combatantId,
|
combatantId,
|
||||||
|
hpDelta,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
creatureId as makeCreatureId,
|
creatureId as makeCreatureId,
|
||||||
pushUndo,
|
pushUndo,
|
||||||
@@ -39,6 +47,7 @@ import {
|
|||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||||
import { useAdapters } from "../contexts/adapter-context.js";
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
|
import type { SearchResult } from "./use-bestiary.js";
|
||||||
|
|
||||||
// -- Types --
|
// -- Types --
|
||||||
|
|
||||||
@@ -54,21 +63,50 @@ type EncounterAction =
|
|||||||
| { 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 }
|
||||||
| { type: "set-cr"; id: CombatantId; value: string | undefined }
|
| { type: "set-cr"; id: CombatantId; value: string | undefined }
|
||||||
|
| { type: "set-side"; id: CombatantId; value: "party" | "enemy" }
|
||||||
| {
|
| {
|
||||||
type: "toggle-condition";
|
type: "toggle-condition";
|
||||||
id: CombatantId;
|
id: CombatantId;
|
||||||
conditionId: ConditionId;
|
conditionId: ConditionId;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "set-condition-value";
|
||||||
|
id: CombatantId;
|
||||||
|
conditionId: ConditionId;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "decrement-condition";
|
||||||
|
id: CombatantId;
|
||||||
|
conditionId: ConditionId;
|
||||||
|
}
|
||||||
| { type: "toggle-concentration"; id: CombatantId }
|
| { type: "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" }
|
||||||
| { type: "add-from-bestiary"; entry: BestiaryIndexEntry }
|
| { type: "add-from-bestiary"; entry: SearchResult }
|
||||||
| {
|
| {
|
||||||
type: "add-multiple-from-bestiary";
|
type: "add-multiple-from-bestiary";
|
||||||
entry: BestiaryIndexEntry;
|
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";
|
||||||
@@ -154,7 +192,7 @@ function resolveAndRename(store: EncounterStore, name: string): string {
|
|||||||
|
|
||||||
function addOneFromBestiary(
|
function addOneFromBestiary(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
entry: BestiaryIndexEntry,
|
entry: SearchResult,
|
||||||
nextId: number,
|
nextId: number,
|
||||||
): {
|
): {
|
||||||
cId: CreatureId;
|
cId: CreatureId;
|
||||||
@@ -213,7 +251,7 @@ function handleUndoRedo(
|
|||||||
|
|
||||||
function handleAddFromBestiary(
|
function handleAddFromBestiary(
|
||||||
state: EncounterState,
|
state: EncounterState,
|
||||||
entry: BestiaryIndexEntry,
|
entry: SearchResult,
|
||||||
count: number,
|
count: number,
|
||||||
): EncounterState {
|
): EncounterState {
|
||||||
const { store, getEncounter } = makeStoreFromState(state);
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
@@ -264,6 +302,76 @@ function handleAddFromPlayerCharacter(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyNamePrefix(
|
||||||
|
name: string,
|
||||||
|
oldAdj: "weak" | "elite" | undefined,
|
||||||
|
newAdj: "weak" | "elite" | undefined,
|
||||||
|
): string {
|
||||||
|
let base = name;
|
||||||
|
if (oldAdj === "weak" && name.startsWith("Weak ")) base = name.slice(5);
|
||||||
|
else if (oldAdj === "elite" && name.startsWith("Elite "))
|
||||||
|
base = name.slice(6);
|
||||||
|
if (newAdj === "weak") return `Weak ${base}`;
|
||||||
|
if (newAdj === "elite") return `Elite ${base}`;
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSetCreatureAdjustment(
|
||||||
|
state: EncounterState,
|
||||||
|
id: CombatantId,
|
||||||
|
adjustment: "weak" | "elite" | undefined,
|
||||||
|
baseCreature: Pf2eCreature,
|
||||||
|
): EncounterState {
|
||||||
|
const combatant = state.encounter.combatants.find((c) => c.id === id);
|
||||||
|
if (!combatant) return state;
|
||||||
|
|
||||||
|
const oldAdj = combatant.creatureAdjustment;
|
||||||
|
if (oldAdj === adjustment) return state;
|
||||||
|
|
||||||
|
const baseLevel = baseCreature.level;
|
||||||
|
const oldHpDelta = oldAdj ? hpDelta(baseLevel, oldAdj) : 0;
|
||||||
|
const newHpDelta = adjustment ? hpDelta(baseLevel, adjustment) : 0;
|
||||||
|
const netHpDelta = newHpDelta - oldHpDelta;
|
||||||
|
|
||||||
|
const oldAcDelta = oldAdj ? acDelta(oldAdj) : 0;
|
||||||
|
const newAcDelta = adjustment ? acDelta(adjustment) : 0;
|
||||||
|
const netAcDelta = newAcDelta - oldAcDelta;
|
||||||
|
|
||||||
|
const newMaxHp =
|
||||||
|
combatant.maxHp === undefined ? undefined : combatant.maxHp + netHpDelta;
|
||||||
|
const newCurrentHp =
|
||||||
|
combatant.currentHp === undefined || newMaxHp === undefined
|
||||||
|
? undefined
|
||||||
|
: Math.max(0, Math.min(combatant.currentHp + netHpDelta, newMaxHp));
|
||||||
|
const newAc =
|
||||||
|
combatant.ac === undefined ? undefined : combatant.ac + netAcDelta;
|
||||||
|
const newName = applyNamePrefix(combatant.name, oldAdj, adjustment);
|
||||||
|
|
||||||
|
const updatedCombatant: typeof combatant = {
|
||||||
|
...combatant,
|
||||||
|
name: newName,
|
||||||
|
...(newMaxHp !== undefined && { maxHp: newMaxHp }),
|
||||||
|
...(newCurrentHp !== undefined && { currentHp: newCurrentHp }),
|
||||||
|
...(newAc !== undefined && { ac: newAc }),
|
||||||
|
...(adjustment === undefined
|
||||||
|
? { creatureAdjustment: undefined }
|
||||||
|
: { creatureAdjustment: adjustment }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const combatants = state.encounter.combatants.map((c) =>
|
||||||
|
c.id === id ? updatedCombatant : c,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
encounter: { ...state.encounter, combatants },
|
||||||
|
events: [
|
||||||
|
...state.events,
|
||||||
|
{ type: "CreatureAdjustmentSet", combatantId: id, adjustment },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// -- Reducer --
|
// -- Reducer --
|
||||||
|
|
||||||
export function encounterReducer(
|
export function encounterReducer(
|
||||||
@@ -295,6 +403,13 @@ export function encounterReducer(
|
|||||||
lastCreatureId: null,
|
lastCreatureId: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "set-creature-adjustment":
|
||||||
|
return handleSetCreatureAdjustment(
|
||||||
|
state,
|
||||||
|
action.id,
|
||||||
|
action.adjustment,
|
||||||
|
action.baseCreature,
|
||||||
|
);
|
||||||
case "add-from-bestiary":
|
case "add-from-bestiary":
|
||||||
return handleAddFromBestiary(state, action.entry, 1);
|
return handleAddFromBestiary(state, action.entry, 1);
|
||||||
case "add-multiple-from-bestiary":
|
case "add-multiple-from-bestiary":
|
||||||
@@ -321,8 +436,13 @@ function dispatchEncounterAction(
|
|||||||
| { type: "set-temp-hp" }
|
| { type: "set-temp-hp" }
|
||||||
| { type: "set-ac" }
|
| { type: "set-ac" }
|
||||||
| { type: "set-cr" }
|
| { type: "set-cr" }
|
||||||
|
| { type: "set-side" }
|
||||||
| { type: "toggle-condition" }
|
| { type: "toggle-condition" }
|
||||||
|
| { type: "set-condition-value" }
|
||||||
|
| { 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);
|
||||||
@@ -364,12 +484,41 @@ function dispatchEncounterAction(
|
|||||||
case "set-cr":
|
case "set-cr":
|
||||||
result = setCrUseCase(store, action.id, action.value);
|
result = setCrUseCase(store, action.id, action.value);
|
||||||
break;
|
break;
|
||||||
|
case "set-side":
|
||||||
|
result = setSideUseCase(store, action.id, action.value);
|
||||||
|
break;
|
||||||
case "toggle-condition":
|
case "toggle-condition":
|
||||||
result = toggleConditionUseCase(store, action.id, action.conditionId);
|
result = toggleConditionUseCase(store, action.id, action.conditionId);
|
||||||
break;
|
break;
|
||||||
|
case "set-condition-value":
|
||||||
|
result = setConditionValueUseCase(
|
||||||
|
store,
|
||||||
|
action.id,
|
||||||
|
action.conditionId,
|
||||||
|
action.value,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "decrement-condition":
|
||||||
|
result = decrementConditionUseCase(store, action.id, action.conditionId);
|
||||||
|
break;
|
||||||
case "toggle-concentration":
|
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;
|
||||||
@@ -389,7 +538,10 @@ function dispatchEncounterAction(
|
|||||||
export function useEncounter() {
|
export function useEncounter() {
|
||||||
const { encounterPersistence, undoRedoPersistence } = useAdapters();
|
const { encounterPersistence, undoRedoPersistence } = useAdapters();
|
||||||
const [state, dispatch] = useReducer(encounterReducer, null, () =>
|
const [state, dispatch] = useReducer(encounterReducer, null, () =>
|
||||||
initializeState(encounterPersistence.load, undoRedoPersistence.load),
|
initializeState(
|
||||||
|
() => encounterPersistence.load(),
|
||||||
|
() => undoRedoPersistence.load(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const { encounter, undoRedoState, events } = state;
|
const { encounter, undoRedoState, events } = state;
|
||||||
|
|
||||||
@@ -506,28 +658,64 @@ export function useEncounter() {
|
|||||||
dispatch({ type: "set-cr", id, value }),
|
dispatch({ type: "set-cr", id, value }),
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
|
setSide: useCallback(
|
||||||
|
(id: CombatantId, value: "party" | "enemy") =>
|
||||||
|
dispatch({ type: "set-side", id, value }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
toggleCondition: useCallback(
|
toggleCondition: useCallback(
|
||||||
(id: CombatantId, conditionId: ConditionId) =>
|
(id: CombatantId, conditionId: ConditionId) =>
|
||||||
dispatch({ type: "toggle-condition", id, conditionId }),
|
dispatch({ type: "toggle-condition", id, conditionId }),
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
|
setConditionValue: useCallback(
|
||||||
|
(id: CombatantId, conditionId: ConditionId, value: number) =>
|
||||||
|
dispatch({ type: "set-condition-value", id, conditionId, value }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
decrementCondition: useCallback(
|
||||||
|
(id: CombatantId, conditionId: ConditionId) =>
|
||||||
|
dispatch({ type: "decrement-condition", id, conditionId }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
toggleConcentration: useCallback(
|
toggleConcentration: useCallback(
|
||||||
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
|
addPersistentDamage: useCallback(
|
||||||
|
(id: CombatantId, damageType: PersistentDamageType, formula: string) =>
|
||||||
|
dispatch({ type: "add-persistent-damage", id, damageType, formula }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
removePersistentDamage: useCallback(
|
||||||
|
(id: CombatantId, damageType: PersistentDamageType) =>
|
||||||
|
dispatch({ type: "remove-persistent-damage", id, damageType }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
setCreatureAdjustment: useCallback(
|
||||||
|
(
|
||||||
|
id: CombatantId,
|
||||||
|
adjustment: "weak" | "elite" | undefined,
|
||||||
|
baseCreature: Pf2eCreature,
|
||||||
|
) =>
|
||||||
|
dispatch({
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id,
|
||||||
|
adjustment,
|
||||||
|
baseCreature,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
),
|
||||||
clearEncounter: useCallback(
|
clearEncounter: useCallback(
|
||||||
() => dispatch({ type: "clear-encounter" }),
|
() => dispatch({ type: "clear-encounter" }),
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
addFromBestiary: useCallback(
|
addFromBestiary: useCallback((entry: SearchResult): CreatureId | null => {
|
||||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
|
||||||
dispatch({ type: "add-from-bestiary", entry });
|
dispatch({ type: "add-from-bestiary", entry });
|
||||||
return null;
|
return null;
|
||||||
},
|
}, []),
|
||||||
[],
|
|
||||||
),
|
|
||||||
addMultipleFromBestiary: useCallback(
|
addMultipleFromBestiary: useCallback(
|
||||||
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
|
(entry: SearchResult, count: number): CreatureId | null => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "add-multiple-from-bestiary",
|
type: "add-multiple-from-bestiary",
|
||||||
entry,
|
entry,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { RulesEdition } from "@initiative/domain";
|
import type { RulesEdition } from "@initiative/domain";
|
||||||
import { useCallback, useSyncExternalStore } from "react";
|
import { useCallback, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
const STORAGE_KEY = "initiative:rules-edition";
|
const STORAGE_KEY = "initiative:game-system";
|
||||||
|
const OLD_STORAGE_KEY = "initiative:rules-edition";
|
||||||
|
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
let currentEdition: RulesEdition = loadEdition();
|
let currentEdition: RulesEdition = loadEdition();
|
||||||
@@ -9,7 +10,14 @@ let currentEdition: RulesEdition = loadEdition();
|
|||||||
function loadEdition(): RulesEdition {
|
function loadEdition(): RulesEdition {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (raw === "5e" || raw === "5.5e") return raw;
|
if (raw === "5e" || raw === "5.5e" || raw === "pf2e") return raw;
|
||||||
|
// Migrate from old key
|
||||||
|
const old = localStorage.getItem(OLD_STORAGE_KEY);
|
||||||
|
if (old === "5e" || old === "5.5e") {
|
||||||
|
localStorage.setItem(STORAGE_KEY, old);
|
||||||
|
localStorage.removeItem(OLD_STORAGE_KEY);
|
||||||
|
return old;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// storage unavailable
|
// storage unavailable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import type { CreatureId } from "@initiative/domain";
|
import type { CombatantId, CreatureId } from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
type PanelView =
|
type PanelView =
|
||||||
| { mode: "closed" }
|
| { mode: "closed" }
|
||||||
| { mode: "creature"; creatureId: CreatureId }
|
| { mode: "creature"; creatureId: CreatureId; combatantId?: CombatantId }
|
||||||
| { mode: "bulk-import" }
|
| { mode: "bulk-import" }
|
||||||
| { mode: "source-manager" };
|
| { mode: "source-manager" };
|
||||||
|
|
||||||
interface SidePanelState {
|
interface SidePanelState {
|
||||||
panelView: PanelView;
|
panelView: PanelView;
|
||||||
selectedCreatureId: CreatureId | null;
|
selectedCreatureId: CreatureId | null;
|
||||||
|
selectedCombatantId: CombatantId | null;
|
||||||
bulkImportMode: boolean;
|
bulkImportMode: boolean;
|
||||||
sourceManagerMode: boolean;
|
sourceManagerMode: boolean;
|
||||||
isRightPanelCollapsed: boolean;
|
isRightPanelCollapsed: boolean;
|
||||||
@@ -18,8 +19,8 @@ interface SidePanelState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SidePanelActions {
|
interface SidePanelActions {
|
||||||
showCreature: (creatureId: CreatureId) => void;
|
showCreature: (creatureId: CreatureId, combatantId?: CombatantId) => void;
|
||||||
updateCreature: (creatureId: CreatureId) => void;
|
updateCreature: (creatureId: CreatureId, combatantId?: CombatantId) => void;
|
||||||
showBulkImport: () => void;
|
showBulkImport: () => void;
|
||||||
showSourceManager: () => void;
|
showSourceManager: () => void;
|
||||||
dismissPanel: () => void;
|
dismissPanel: () => void;
|
||||||
@@ -48,14 +49,23 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
|||||||
const selectedCreatureId =
|
const selectedCreatureId =
|
||||||
panelView.mode === "creature" ? panelView.creatureId : null;
|
panelView.mode === "creature" ? panelView.creatureId : null;
|
||||||
|
|
||||||
const showCreature = useCallback((creatureId: CreatureId) => {
|
const selectedCombatantId =
|
||||||
setPanelView({ mode: "creature", creatureId });
|
panelView.mode === "creature" ? (panelView.combatantId ?? null) : null;
|
||||||
setIsRightPanelCollapsed(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateCreature = useCallback((creatureId: CreatureId) => {
|
const showCreature = useCallback(
|
||||||
setPanelView({ mode: "creature", creatureId });
|
(creatureId: CreatureId, combatantId?: CombatantId) => {
|
||||||
}, []);
|
setPanelView({ mode: "creature", creatureId, combatantId });
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCreature = useCallback(
|
||||||
|
(creatureId: CreatureId, combatantId?: CombatantId) => {
|
||||||
|
setPanelView({ mode: "creature", creatureId, combatantId });
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const showBulkImport = useCallback(() => {
|
const showBulkImport = useCallback(() => {
|
||||||
setPanelView({ mode: "bulk-import" });
|
setPanelView({ mode: "bulk-import" });
|
||||||
@@ -90,6 +100,7 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
|||||||
return {
|
return {
|
||||||
panelView,
|
panelView,
|
||||||
selectedCreatureId,
|
selectedCreatureId,
|
||||||
|
selectedCombatantId,
|
||||||
bulkImportMode: panelView.mode === "bulk-import",
|
bulkImportMode: panelView.mode === "bulk-import",
|
||||||
sourceManagerMode: panelView.mode === "source-manager",
|
sourceManagerMode: panelView.mode === "source-manager",
|
||||||
isRightPanelCollapsed,
|
isRightPanelCollapsed,
|
||||||
|
|||||||
@@ -70,3 +70,72 @@ export function useSwipeToDismiss(onDismiss: () => void) {
|
|||||||
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical (down-only) variant for dismissing bottom sheets via swipe-down.
|
||||||
|
* Mirrors `useSwipeToDismiss` but locks to vertical direction and tracks
|
||||||
|
* the sheet height instead of width.
|
||||||
|
*/
|
||||||
|
export function useSwipeToDismissDown(onDismiss: () => void) {
|
||||||
|
const [swipe, setSwipe] = useState<SwipeState>({
|
||||||
|
offsetX: 0,
|
||||||
|
isSwiping: false,
|
||||||
|
});
|
||||||
|
const startX = useRef(0);
|
||||||
|
const startY = useRef(0);
|
||||||
|
const startTime = useRef(0);
|
||||||
|
const sheetHeight = useRef(0);
|
||||||
|
const directionLocked = useRef<"horizontal" | "vertical" | null>(null);
|
||||||
|
|
||||||
|
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
startX.current = touch.clientX;
|
||||||
|
startY.current = touch.clientY;
|
||||||
|
startTime.current = Date.now();
|
||||||
|
directionLocked.current = null;
|
||||||
|
const el = e.currentTarget as HTMLElement;
|
||||||
|
sheetHeight.current = el.getBoundingClientRect().height;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const dx = touch.clientX - startX.current;
|
||||||
|
const dy = touch.clientY - startY.current;
|
||||||
|
|
||||||
|
if (!directionLocked.current) {
|
||||||
|
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
|
||||||
|
directionLocked.current =
|
||||||
|
Math.abs(dy) > Math.abs(dx) ? "vertical" : "horizontal";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directionLocked.current === "horizontal") return;
|
||||||
|
|
||||||
|
const clampedY = Math.max(0, dy);
|
||||||
|
// `offsetX` is reused as the vertical offset to keep SwipeState shared.
|
||||||
|
setSwipe({ offsetX: clampedY, isSwiping: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchEnd = useCallback(() => {
|
||||||
|
if (directionLocked.current !== "vertical") {
|
||||||
|
setSwipe({ offsetX: 0, isSwiping: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = (Date.now() - startTime.current) / 1000;
|
||||||
|
const velocity = swipe.offsetX / elapsed / sheetHeight.current;
|
||||||
|
const ratio =
|
||||||
|
sheetHeight.current > 0 ? swipe.offsetX / sheetHeight.current : 0;
|
||||||
|
|
||||||
|
if (ratio > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSwipe({ offsetX: 0, isSwiping: false });
|
||||||
|
}, [swipe.offsetX, onDismiss]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
offsetY: swipe.offsetX,
|
||||||
|
isSwiping: swipe.isSwiping,
|
||||||
|
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,6 +103,19 @@
|
|||||||
animation: slide-in-right 200ms ease-out;
|
animation: slide-in-right 200ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-bottom {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-slide-in-bottom {
|
||||||
|
animation: slide-in-bottom 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes confirm-pulse {
|
@keyframes confirm-pulse {
|
||||||
0% {
|
0% {
|
||||||
scale: 1;
|
scale: 1;
|
||||||
|
|||||||
@@ -154,6 +154,47 @@ describe("loadEncounter", () => {
|
|||||||
expect(loaded?.combatants[0].cr).toBe("2");
|
expect(loaded?.combatants[0].cr).toBe("2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trip preserves combatant side field", () => {
|
||||||
|
const result = createEncounter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Allied Guard",
|
||||||
|
cr: "2",
|
||||||
|
side: "party",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Goblin",
|
||||||
|
side: "enemy",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error("unreachable");
|
||||||
|
saveEncounter(result);
|
||||||
|
const loaded = loadEncounter();
|
||||||
|
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded?.combatants[0].side).toBe("party");
|
||||||
|
expect(loaded?.combatants[1].side).toBe("enemy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trip preserves combatant without side field as undefined", () => {
|
||||||
|
const result = createEncounter(
|
||||||
|
[{ id: combatantId("c-1"), name: "Custom" }],
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error("unreachable");
|
||||||
|
saveEncounter(result);
|
||||||
|
const loaded = loadEncounter();
|
||||||
|
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded?.combatants[0].side).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("saving after modifications persists the latest state", () => {
|
it("saving after modifications persists the latest state", () => {
|
||||||
const encounter = makeEncounter();
|
const encounter = makeEncounter();
|
||||||
saveEncounter(encounter);
|
saveEncounter(encounter);
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"!coverage",
|
"!coverage",
|
||||||
"!.pnpm-store",
|
"!.pnpm-store",
|
||||||
"!.rodney",
|
"!.rodney",
|
||||||
"!.agent-tests"
|
"!.agent-tests",
|
||||||
|
"!data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"assist": {
|
"assist": {
|
||||||
|
|||||||
1
data/bestiary/pf2e-index.json
Normal file
1
data/bestiary/pf2e-index.json
Normal file
File diff suppressed because one or more lines are too long
@@ -31,7 +31,7 @@
|
|||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"jscpd": "jscpd",
|
"jscpd": "jscpd",
|
||||||
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
|
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
|
||||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny warnings",
|
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny-warnings",
|
||||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||||
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||||
"check:props": "node scripts/check-component-props.mjs",
|
"check:props": "node scripts/check-component-props.mjs",
|
||||||
|
|||||||
@@ -292,9 +292,9 @@ describe("toggleConditionUseCase", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(isDomainError(result)).toBe(false);
|
expect(isDomainError(result)).toBe(false);
|
||||||
expect(requireSaved(store.saved).combatants[0].conditions).toContain(
|
expect(requireSaved(store.saved).combatants[0].conditions).toContainEqual({
|
||||||
"blinded",
|
id: "blinded",
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns domain error for unknown combatant", () => {
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
|||||||
20
packages/application/src/add-persistent-damage-use-case.ts
Normal file
20
packages/application/src/add-persistent-damage-use-case.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import {
|
||||||
|
addPersistentDamage,
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
type PersistentDamageType,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
|
export function addPersistentDamageUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
formula: string,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
return runEncounterAction(store, (encounter) =>
|
||||||
|
addPersistentDamage(encounter, combatantId, damageType, formula),
|
||||||
|
);
|
||||||
|
}
|
||||||
21
packages/application/src/creature-initiative-modifier.ts
Normal file
21
packages/application/src/creature-initiative-modifier.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
type AnyCreature,
|
||||||
|
calculateInitiative,
|
||||||
|
calculatePf2eInitiative,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
|
||||||
|
export function creatureInitiativeModifier(creature: AnyCreature): number {
|
||||||
|
if ("system" in creature && creature.system === "pf2e") {
|
||||||
|
return calculatePf2eInitiative(creature.perception).modifier;
|
||||||
|
}
|
||||||
|
const c = creature as {
|
||||||
|
abilities: { dex: number };
|
||||||
|
cr: string;
|
||||||
|
initiativeProficiency: number;
|
||||||
|
};
|
||||||
|
return calculateInitiative({
|
||||||
|
dexScore: c.abilities.dex,
|
||||||
|
cr: c.cr,
|
||||||
|
initiativeProficiency: c.initiativeProficiency,
|
||||||
|
}).modifier;
|
||||||
|
}
|
||||||
19
packages/application/src/decrement-condition-use-case.ts
Normal file
19
packages/application/src/decrement-condition-use-case.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type ConditionId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
decrementCondition,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
|
export function decrementConditionUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
conditionId: ConditionId,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
return runEncounterAction(store, (encounter) =>
|
||||||
|
decrementCondition(encounter, combatantId, conditionId),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
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";
|
||||||
export { createPlayerCharacterUseCase } from "./create-player-character-use-case.js";
|
export { createPlayerCharacterUseCase } from "./create-player-character-use-case.js";
|
||||||
|
export { decrementConditionUseCase } from "./decrement-condition-use-case.js";
|
||||||
export { deletePlayerCharacterUseCase } from "./delete-player-character-use-case.js";
|
export { deletePlayerCharacterUseCase } from "./delete-player-character-use-case.js";
|
||||||
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
||||||
export { editPlayerCharacterUseCase } from "./edit-player-character-use-case.js";
|
export { editPlayerCharacterUseCase } from "./edit-player-character-use-case.js";
|
||||||
@@ -14,6 +16,7 @@ export type {
|
|||||||
} from "./ports.js";
|
} from "./ports.js";
|
||||||
export { redoUseCase } from "./redo-use-case.js";
|
export { redoUseCase } from "./redo-use-case.js";
|
||||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||||
|
export { removePersistentDamageUseCase } from "./remove-persistent-damage-use-case.js";
|
||||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||||
export {
|
export {
|
||||||
type RollAllResult,
|
type RollAllResult,
|
||||||
@@ -21,9 +24,11 @@ export {
|
|||||||
} from "./roll-all-initiative-use-case.js";
|
} from "./roll-all-initiative-use-case.js";
|
||||||
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
||||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||||
|
export { setConditionValueUseCase } from "./set-condition-value-use-case.js";
|
||||||
export { setCrUseCase } from "./set-cr-use-case.js";
|
export { setCrUseCase } from "./set-cr-use-case.js";
|
||||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
export { setHpUseCase } from "./set-hp-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 { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||||
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||||
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
Creature,
|
AnyCreature,
|
||||||
CreatureId,
|
CreatureId,
|
||||||
Encounter,
|
Encounter,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
@@ -12,7 +12,7 @@ export interface EncounterStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BestiarySourceCache {
|
export interface BestiarySourceCache {
|
||||||
getCreature(creatureId: CreatureId): Creature | undefined;
|
getCreature(creatureId: CreatureId): AnyCreature | undefined;
|
||||||
isSourceCached(sourceCode: string): boolean;
|
isSourceCached(sourceCode: string): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
type PersistentDamageType,
|
||||||
|
removePersistentDamage,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
|
export function removePersistentDamageUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
return runEncounterAction(store, (encounter) =>
|
||||||
|
removePersistentDamage(encounter, combatantId, damageType),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type Creature,
|
type AnyCreature,
|
||||||
type CreatureId,
|
type CreatureId,
|
||||||
calculateInitiative,
|
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
@@ -10,6 +9,7 @@ import {
|
|||||||
selectRoll,
|
selectRoll,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
import { creatureInitiativeModifier } from "./creature-initiative-modifier.js";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
export interface RollAllResult {
|
export interface RollAllResult {
|
||||||
@@ -20,7 +20,7 @@ export interface RollAllResult {
|
|||||||
export function rollAllInitiativeUseCase(
|
export function rollAllInitiativeUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
rollDice: () => number,
|
rollDice: () => number,
|
||||||
getCreature: (id: CreatureId) => Creature | undefined,
|
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||||
mode: RollMode = "normal",
|
mode: RollMode = "normal",
|
||||||
): RollAllResult | DomainError {
|
): RollAllResult | DomainError {
|
||||||
let encounter = store.get();
|
let encounter = store.get();
|
||||||
@@ -37,11 +37,7 @@ export function rollAllInitiativeUseCase(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { modifier } = calculateInitiative({
|
const modifier = creatureInitiativeModifier(creature);
|
||||||
dexScore: creature.abilities.dex,
|
|
||||||
cr: creature.cr,
|
|
||||||
initiativeProficiency: creature.initiativeProficiency,
|
|
||||||
});
|
|
||||||
const roll1 = rollDice();
|
const roll1 = rollDice();
|
||||||
const effectiveRoll =
|
const effectiveRoll =
|
||||||
mode === "normal" ? roll1 : selectRoll(roll1, rollDice(), mode);
|
mode === "normal" ? roll1 : selectRoll(roll1, rollDice(), mode);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
|
type AnyCreature,
|
||||||
type CombatantId,
|
type CombatantId,
|
||||||
type Creature,
|
|
||||||
type CreatureId,
|
type CreatureId,
|
||||||
calculateInitiative,
|
|
||||||
type DomainError,
|
type DomainError,
|
||||||
type DomainEvent,
|
type DomainEvent,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
@@ -11,13 +10,14 @@ import {
|
|||||||
selectRoll,
|
selectRoll,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
import { creatureInitiativeModifier } from "./creature-initiative-modifier.js";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
export function rollInitiativeUseCase(
|
export function rollInitiativeUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
combatantId: CombatantId,
|
combatantId: CombatantId,
|
||||||
diceRolls: readonly [number, ...number[]],
|
diceRolls: readonly [number, ...number[]],
|
||||||
getCreature: (id: CreatureId) => Creature | undefined,
|
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||||
mode: RollMode = "normal",
|
mode: RollMode = "normal",
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const encounter = store.get();
|
const encounter = store.get();
|
||||||
@@ -48,11 +48,7 @@ export function rollInitiativeUseCase(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { modifier } = calculateInitiative({
|
const modifier = creatureInitiativeModifier(creature);
|
||||||
dexScore: creature.abilities.dex,
|
|
||||||
cr: creature.cr,
|
|
||||||
initiativeProficiency: creature.initiativeProficiency,
|
|
||||||
});
|
|
||||||
const effectiveRoll =
|
const effectiveRoll =
|
||||||
mode === "normal"
|
mode === "normal"
|
||||||
? diceRolls[0]
|
? diceRolls[0]
|
||||||
|
|||||||
20
packages/application/src/set-condition-value-use-case.ts
Normal file
20
packages/application/src/set-condition-value-use-case.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type ConditionId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
setConditionValue,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
|
export function setConditionValueUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
conditionId: ConditionId,
|
||||||
|
value: number,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
return runEncounterAction(store, (encounter) =>
|
||||||
|
setConditionValue(encounter, combatantId, conditionId, value),
|
||||||
|
);
|
||||||
|
}
|
||||||
18
packages/application/src/set-side-use-case.ts
Normal file
18
packages/application/src/set-side-use-case.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
setSide,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
|
export function setSideUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
value: "party" | "enemy",
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
return runEncounterAction(store, (encounter) =>
|
||||||
|
setSide(encounter, combatantId, value),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ describe("clearEncounter", () => {
|
|||||||
maxHp: 50,
|
maxHp: 50,
|
||||||
currentHp: 30,
|
currentHp: 30,
|
||||||
ac: 18,
|
ac: 18,
|
||||||
conditions: ["blinded", "poisoned"],
|
conditions: [{ id: "blinded" }, { id: "poisoned" }],
|
||||||
isConcentrating: true,
|
isConcentrating: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -63,7 +63,7 @@ describe("clearEncounter", () => {
|
|||||||
maxHp: 25,
|
maxHp: 25,
|
||||||
currentHp: 0,
|
currentHp: 0,
|
||||||
ac: 12,
|
ac: 12,
|
||||||
conditions: ["unconscious"],
|
conditions: [{ id: "unconscious" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
activeIndex: 0,
|
activeIndex: 0,
|
||||||
|
|||||||
@@ -26,25 +26,40 @@ describe("getConditionDescription", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("universal conditions have both descriptions", () => {
|
it("returns pf2e description when edition is pf2e", () => {
|
||||||
const universal = CONDITION_DEFINITIONS.filter(
|
const blinded = findCondition("blinded");
|
||||||
(d) => d.edition === undefined,
|
expect(getConditionDescription(blinded, "pf2e")).toBe(
|
||||||
|
blinded.descriptionPf2e,
|
||||||
);
|
);
|
||||||
expect(universal.length).toBeGreaterThan(0);
|
});
|
||||||
for (const def of universal) {
|
|
||||||
|
it("falls back to default description for pf2e when no pf2e text", () => {
|
||||||
|
const paralyzed = findCondition("paralyzed");
|
||||||
|
expect(getConditionDescription(paralyzed, "pf2e")).toBe(
|
||||||
|
paralyzed.descriptionPf2e,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shared D&D conditions have both description and description5e", () => {
|
||||||
|
const sharedDndConditions = CONDITION_DEFINITIONS.filter(
|
||||||
|
(d) =>
|
||||||
|
d.systems === undefined ||
|
||||||
|
(d.systems.includes("5e") && d.systems.includes("5.5e")),
|
||||||
|
);
|
||||||
|
for (const def of sharedDndConditions) {
|
||||||
expect(def.description).toBeTruthy();
|
expect(def.description).toBeTruthy();
|
||||||
expect(def.description5e).toBeTruthy();
|
expect(def.description5e).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("edition-specific conditions have their edition description", () => {
|
it("system-specific conditions use the systems field", () => {
|
||||||
const sapped = findCondition("sapped");
|
const sapped = findCondition("sapped");
|
||||||
expect(sapped.description).toBeTruthy();
|
expect(sapped.description).toBeTruthy();
|
||||||
expect(sapped.edition).toBe("5.5e");
|
expect(sapped.systems).toContain("5.5e");
|
||||||
|
|
||||||
const slowed = findCondition("slowed");
|
const slowed = findCondition("slowed");
|
||||||
expect(slowed.description).toBeTruthy();
|
expect(slowed.description).toBeTruthy();
|
||||||
expect(slowed.edition).toBe("5.5e");
|
expect(slowed.systems).toContain("5.5e");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("conditions with identical rules share the same text", () => {
|
it("conditions with identical rules share the same text", () => {
|
||||||
@@ -79,4 +94,34 @@ describe("getConditionsForEdition", () => {
|
|||||||
expect(ids5e).toContain("blinded");
|
expect(ids5e).toContain("blinded");
|
||||||
expect(ids55e).toContain("blinded");
|
expect(ids55e).toContain("blinded");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns PF2e conditions for pf2e edition", () => {
|
||||||
|
const conditions = getConditionsForEdition("pf2e");
|
||||||
|
const ids = conditions.map((d) => d.id);
|
||||||
|
expect(ids).toContain("clumsy");
|
||||||
|
expect(ids).toContain("drained");
|
||||||
|
expect(ids).toContain("off-guard");
|
||||||
|
expect(ids).toContain("sickened");
|
||||||
|
expect(ids).not.toContain("charmed");
|
||||||
|
expect(ids).not.toContain("exhaustion");
|
||||||
|
expect(ids).not.toContain("grappled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns D&D conditions for 5.5e", () => {
|
||||||
|
const conditions = getConditionsForEdition("5.5e");
|
||||||
|
const ids = conditions.map((d) => d.id);
|
||||||
|
expect(ids).toContain("charmed");
|
||||||
|
expect(ids).toContain("exhaustion");
|
||||||
|
expect(ids).not.toContain("clumsy");
|
||||||
|
expect(ids).not.toContain("off-guard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shared conditions appear in both D&D and PF2e", () => {
|
||||||
|
const dndIds = getConditionsForEdition("5.5e").map((d) => d.id);
|
||||||
|
const pf2eIds = getConditionsForEdition("pf2e").map((d) => d.id);
|
||||||
|
expect(dndIds).toContain("blinded");
|
||||||
|
expect(pf2eIds).toContain("blinded");
|
||||||
|
expect(dndIds).toContain("prone");
|
||||||
|
expect(pf2eIds).toContain("prone");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,98 +36,353 @@ describe("crToXp", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("calculateEncounterDifficulty", () => {
|
/** Helper to build party-side descriptors with level. */
|
||||||
it("returns trivial when monster XP is below Low threshold", () => {
|
function party(level: number) {
|
||||||
|
return { level, side: "party" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper to build enemy-side descriptors with CR. */
|
||||||
|
function enemy(cr: string) {
|
||||||
|
return { cr, side: "enemy" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
||||||
|
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 → trivial
|
// 1x CR 0 = 0 XP -> tier 0
|
||||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["0"]);
|
const result = calculateEncounterDifficulty(
|
||||||
expect(result.tier).toBe("trivial");
|
[party(1), party(1), party(1), party(1), enemy("0")],
|
||||||
|
"5.5e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(0);
|
||||||
expect(result.totalMonsterXp).toBe(0);
|
expect(result.totalMonsterXp).toBe(0);
|
||||||
expect(result.partyBudget).toEqual({
|
expect(result.thresholds).toEqual([
|
||||||
low: 200,
|
{ label: "Low", value: 200 },
|
||||||
moderate: 300,
|
{ label: "Moderate", value: 300 },
|
||||||
high: 400,
|
{ label: "High", value: 400 },
|
||||||
});
|
]);
|
||||||
|
expect(result.encounterMultiplier).toBeUndefined();
|
||||||
|
expect(result.adjustedXp).toBeUndefined();
|
||||||
|
expect(result.partySizeAdjusted).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns low for 4x level 1 vs Bugbear (CR 1)", () => {
|
it("returns tier 1 for 4x level 1 vs Bugbear (CR 1)", () => {
|
||||||
// DMG example: 4x level 1 PCs vs 1 Bugbear (CR 1, 200 XP)
|
const result = calculateEncounterDifficulty(
|
||||||
// Low = 200, Moderate = 300 → 200 >= 200 but < 300 → Low
|
[party(1), party(1), party(1), party(1), enemy("1")],
|
||||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1"]);
|
"5.5e",
|
||||||
expect(result.tier).toBe("low");
|
);
|
||||||
|
expect(result.tier).toBe(1);
|
||||||
expect(result.totalMonsterXp).toBe(200);
|
expect(result.totalMonsterXp).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns moderate for 5x level 3 vs 1125 XP", () => {
|
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
|
||||||
// 1125 XP >= 1125 Moderate but < 2000 High → Moderate
|
// CR 3 (700) + CR 2 (450) = 1150 XP >= 1125 Moderate
|
||||||
// Using CR 3 (700) + CR 2 (450) = 1150 XP ≈ 1125 threshold
|
const result = calculateEncounterDifficulty(
|
||||||
// Let's use exact: 5 * 225 = 1125 moderate budget
|
[
|
||||||
// Need monsters that sum exactly to 1125: CR 3 (700) + CR 2 (450) = 1150
|
party(3),
|
||||||
const result = calculateEncounterDifficulty([3, 3, 3, 3, 3], ["3", "2"]);
|
party(3),
|
||||||
expect(result.tier).toBe("moderate");
|
party(3),
|
||||||
|
party(3),
|
||||||
|
party(3),
|
||||||
|
enemy("3"),
|
||||||
|
enemy("2"),
|
||||||
|
],
|
||||||
|
"5.5e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(2);
|
||||||
expect(result.totalMonsterXp).toBe(1150);
|
expect(result.totalMonsterXp).toBe(1150);
|
||||||
expect(result.partyBudget.moderate).toBe(1125);
|
expect(result.thresholds[1].value).toBe(1125);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns high when XP meets High threshold", () => {
|
it("returns tier 3 when XP meets High threshold", () => {
|
||||||
// 4x level 1: High = 400
|
// 4x level 1: High = 400
|
||||||
// 2x CR 1 = 400 XP → High
|
// 2x CR 1 = 400 XP -> tier 3
|
||||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["1", "1"]);
|
const result = calculateEncounterDifficulty(
|
||||||
expect(result.tier).toBe("high");
|
[party(1), party(1), party(1), party(1), enemy("1"), enemy("1")],
|
||||||
|
"5.5e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(3);
|
||||||
expect(result.totalMonsterXp).toBe(400);
|
expect(result.totalMonsterXp).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("caps at high when XP far exceeds threshold", () => {
|
it("caps at tier 3 when XP far exceeds threshold", () => {
|
||||||
// 4x level 1: High = 400
|
const result = calculateEncounterDifficulty(
|
||||||
// CR 30 = 155000 XP → still High (no tier above)
|
[party(1), party(1), party(1), party(1), enemy("30")],
|
||||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["30"]);
|
"5.5e",
|
||||||
expect(result.tier).toBe("high");
|
);
|
||||||
|
expect(result.tier).toBe(3);
|
||||||
expect(result.totalMonsterXp).toBe(155000);
|
expect(result.totalMonsterXp).toBe(155000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles mixed party levels", () => {
|
it("handles mixed party levels", () => {
|
||||||
// 3x level 3 + 1x level 2
|
// 3x level 3 + 1x level 2
|
||||||
// level 3: low=150, mod=225, high=400 (x3 = 450, 675, 1200)
|
|
||||||
// level 2: low=100, mod=150, high=200 (x1 = 100, 150, 200)
|
|
||||||
// Total: low=550, mod=825, high=1400
|
// Total: low=550, mod=825, high=1400
|
||||||
const result = calculateEncounterDifficulty([3, 3, 3, 2], ["3"]);
|
const result = calculateEncounterDifficulty(
|
||||||
expect(result.partyBudget).toEqual({
|
[party(3), party(3), party(3), party(2), enemy("3")],
|
||||||
low: 550,
|
"5.5e",
|
||||||
moderate: 825,
|
);
|
||||||
high: 1400,
|
expect(result.thresholds).toEqual([
|
||||||
});
|
{ label: "Low", value: 550 },
|
||||||
|
{ label: "Moderate", value: 825 },
|
||||||
|
{ label: "High", value: 1400 },
|
||||||
|
]);
|
||||||
expect(result.totalMonsterXp).toBe(700);
|
expect(result.totalMonsterXp).toBe(700);
|
||||||
expect(result.tier).toBe("low");
|
expect(result.tier).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns trivial with empty monster array", () => {
|
it("returns tier 0 with no enemies", () => {
|
||||||
const result = calculateEncounterDifficulty([5, 5], []);
|
const result = calculateEncounterDifficulty([party(5), party(5)], "5.5e");
|
||||||
expect(result.tier).toBe("trivial");
|
expect(result.tier).toBe(0);
|
||||||
expect(result.totalMonsterXp).toBe(0);
|
expect(result.totalMonsterXp).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns high with empty party array (zero budget thresholds)", () => {
|
it("returns tier 3 with no party levels (zero budget thresholds)", () => {
|
||||||
// Domain function treats empty party as zero budgets — any XP exceeds all thresholds.
|
const result = calculateEncounterDifficulty([enemy("1")], "5.5e");
|
||||||
// The useDifficulty hook guards this path by returning null when no leveled PCs exist.
|
expect(result.tier).toBe(3);
|
||||||
const result = calculateEncounterDifficulty([], ["1"]);
|
|
||||||
expect(result.tier).toBe("high");
|
|
||||||
expect(result.totalMonsterXp).toBe(200);
|
expect(result.totalMonsterXp).toBe(200);
|
||||||
expect(result.partyBudget).toEqual({ low: 0, moderate: 0, high: 0 });
|
expect(result.thresholds).toEqual([
|
||||||
|
{ label: "Low", value: 0 },
|
||||||
|
{ label: "Moderate", value: 0 },
|
||||||
|
{ label: "High", value: 0 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles fractional CRs", () => {
|
it("handles fractional CRs", () => {
|
||||||
const result = calculateEncounterDifficulty(
|
const result = calculateEncounterDifficulty(
|
||||||
[1, 1, 1, 1],
|
[
|
||||||
["1/8", "1/4", "1/2"],
|
party(1),
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
enemy("1/8"),
|
||||||
|
enemy("1/4"),
|
||||||
|
enemy("1/2"),
|
||||||
|
],
|
||||||
|
"5.5e",
|
||||||
);
|
);
|
||||||
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
|
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
|
||||||
expect(result.tier).toBe("trivial"); // 175 < 200 Low
|
expect(result.tier).toBe(0); // 175 < 200 Low
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores unknown CRs (0 XP)", () => {
|
it("ignores unknown CRs (0 XP)", () => {
|
||||||
const result = calculateEncounterDifficulty([1, 1, 1, 1], ["unknown"]);
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(1), party(1), party(1), party(1), enemy("unknown")],
|
||||||
|
"5.5e",
|
||||||
|
);
|
||||||
expect(result.totalMonsterXp).toBe(0);
|
expect(result.totalMonsterXp).toBe(0);
|
||||||
expect(result.tier).toBe("trivial");
|
expect(result.tier).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts XP for party-side combatant with CR", () => {
|
||||||
|
// 4x level 1 party, 1 enemy CR 2 (450 XP), 1 party CR 1 (200 XP)
|
||||||
|
// Net = 450 - 200 = 250
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
enemy("2"),
|
||||||
|
{ cr: "1", side: "party" },
|
||||||
|
],
|
||||||
|
"5.5e",
|
||||||
|
);
|
||||||
|
expect(result.totalMonsterXp).toBe(250);
|
||||||
|
expect(result.tier).toBe(1); // 250 >= 200 Low, < 300 Moderate
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors net monster XP at 0", () => {
|
||||||
|
// Party ally has more XP than enemy
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[
|
||||||
|
party(1),
|
||||||
|
{ cr: "5", side: "party" }, // 1800 XP subtracted
|
||||||
|
enemy("1"), // 200 XP added
|
||||||
|
],
|
||||||
|
"5.5e",
|
||||||
|
);
|
||||||
|
expect(result.totalMonsterXp).toBe(0);
|
||||||
|
expect(result.tier).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dual contribution: combatant with both level and CR on party side", () => {
|
||||||
|
// Party combatant with level 1 AND CR 1 on party side
|
||||||
|
// Level contributes to budget, CR subtracts from monster XP
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[
|
||||||
|
{ level: 1, cr: "1", side: "party" }, // budget += lv1, monsterXp -= 200
|
||||||
|
enemy("2"), // monsterXp += 450
|
||||||
|
],
|
||||||
|
"5.5e",
|
||||||
|
);
|
||||||
|
expect(result.thresholds).toEqual([
|
||||||
|
{ label: "Low", value: 50 },
|
||||||
|
{ label: "Moderate", value: 75 },
|
||||||
|
{ label: "High", value: 100 },
|
||||||
|
]);
|
||||||
|
expect(result.totalMonsterXp).toBe(250); // 450 - 200
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enemy-side combatant with level does NOT contribute to budget", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(1), { level: 5, side: "enemy" }, enemy("1")],
|
||||||
|
"5.5e",
|
||||||
|
);
|
||||||
|
// Only level 1 party contributes to budget
|
||||||
|
expect(result.thresholds).toEqual([
|
||||||
|
{ label: "Low", value: 50 },
|
||||||
|
{ label: "Moderate", value: 75 },
|
||||||
|
{ label: "High", value: 100 },
|
||||||
|
]);
|
||||||
|
expect(result.totalMonsterXp).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mixed sides calculate correctly", () => {
|
||||||
|
// 2 party PCs (level 3), 1 party ally (CR 1, 200 XP), 2 enemies (CR 2, 450 each)
|
||||||
|
// Budget: 2x level 3 = low 300, mod 450, high 800
|
||||||
|
// Monster XP: 900 - 200 = 700
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(3), party(3), { cr: "1", side: "party" }, enemy("2"), enemy("2")],
|
||||||
|
"5.5e",
|
||||||
|
);
|
||||||
|
expect(result.thresholds).toEqual([
|
||||||
|
{ label: "Low", value: 300 },
|
||||||
|
{ label: "Moderate", value: 450 },
|
||||||
|
{ label: "High", value: 800 },
|
||||||
|
]);
|
||||||
|
expect(result.totalMonsterXp).toBe(700);
|
||||||
|
expect(result.tier).toBe(2); // 700 >= 450 Moderate, < 800 High
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateEncounterDifficulty — 2014 edition", () => {
|
||||||
|
it("uses 2014 XP thresholds table", () => {
|
||||||
|
// 4x level 1: Easy=100, Medium=200, Hard=300, Deadly=400
|
||||||
|
// 1 enemy CR 1 = 200 XP, x1 multiplier = 200 adjusted
|
||||||
|
// 200 >= 200 Medium → tier 1
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(1), party(1), party(1), party(1), enemy("1")],
|
||||||
|
"5e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(1);
|
||||||
|
expect(result.thresholds).toEqual([
|
||||||
|
{ label: "Easy", value: 100 },
|
||||||
|
{ label: "Medium", value: 200 },
|
||||||
|
{ label: "Hard", value: 300 },
|
||||||
|
{ label: "Deadly", value: 400 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies encounter multiplier for 3 monsters (x2)", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
enemy("1/8"),
|
||||||
|
enemy("1/8"),
|
||||||
|
enemy("1/8"),
|
||||||
|
],
|
||||||
|
"5e",
|
||||||
|
);
|
||||||
|
// Base: 75 XP, 3 monsters → x2 = 150 adjusted
|
||||||
|
expect(result.totalMonsterXp).toBe(75);
|
||||||
|
expect(result.encounterMultiplier).toBe(2);
|
||||||
|
expect(result.adjustedXp).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shifts multiplier up for fewer than 3 PCs", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(1), party(1), enemy("1")],
|
||||||
|
"5e",
|
||||||
|
);
|
||||||
|
// 1 monster, 2 PCs → base x1 shifts up to x1.5
|
||||||
|
expect(result.encounterMultiplier).toBe(1.5);
|
||||||
|
expect(result.partySizeAdjusted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shifts multiplier down for 6+ PCs", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
enemy("1"),
|
||||||
|
enemy("1"),
|
||||||
|
enemy("1"),
|
||||||
|
],
|
||||||
|
"5e",
|
||||||
|
);
|
||||||
|
// 3 monsters, 6 PCs → base x2 shifts down to x1.5
|
||||||
|
expect(result.encounterMultiplier).toBe(1.5);
|
||||||
|
expect(result.partySizeAdjusted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shifts multiplier to x5 for 15+ monsters with <3 PCs", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(1), party(1), ...Array.from({ length: 15 }, () => enemy("0"))],
|
||||||
|
"5e",
|
||||||
|
);
|
||||||
|
// 15+ monsters = x4 base, shift up → x5
|
||||||
|
expect(result.encounterMultiplier).toBe(5);
|
||||||
|
expect(result.partySizeAdjusted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shifts multiplier to x0.5 for 1 monster with 6+ PCs", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(1), party(1), party(1), party(1), party(1), party(1), enemy("1")],
|
||||||
|
"5e",
|
||||||
|
);
|
||||||
|
expect(result.encounterMultiplier).toBe(0.5);
|
||||||
|
expect(result.partySizeAdjusted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only counts enemy-side combatants for monster count", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
party(1),
|
||||||
|
{ cr: "1", side: "party" },
|
||||||
|
enemy("1"),
|
||||||
|
enemy("1"),
|
||||||
|
enemy("1"),
|
||||||
|
],
|
||||||
|
"5e",
|
||||||
|
);
|
||||||
|
// 3 enemy monsters → x2, NOT 4
|
||||||
|
expect(result.encounterMultiplier).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns tier 0 when adjusted XP meets Easy but not Medium", () => {
|
||||||
|
// 4x level 1: Easy=100, Medium=200
|
||||||
|
// 1 enemy CR 1/2 = 100 XP, x1 multiplier = 100 adjusted
|
||||||
|
// 100 >= Easy(100) but < Medium(200) → tier 0
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(1), party(1), party(1), party(1), enemy("1/2")],
|
||||||
|
"5e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(0);
|
||||||
|
expect(result.adjustedXp).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no party size adjustment for standard party (3-5)", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(1), party(1), party(1), party(1), enemy("1")],
|
||||||
|
"5e",
|
||||||
|
);
|
||||||
|
expect(result.partySizeAdjusted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined multiplier/adjustedXp for 5.5e", () => {
|
||||||
|
const result = calculateEncounterDifficulty([party(1), enemy("1")], "5.5e");
|
||||||
|
expect(result.encounterMultiplier).toBeUndefined();
|
||||||
|
expect(result.adjustedXp).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
calculateInitiative,
|
calculateInitiative,
|
||||||
|
calculatePf2eInitiative,
|
||||||
formatInitiativeModifier,
|
formatInitiativeModifier,
|
||||||
} from "../initiative.js";
|
} from "../initiative.js";
|
||||||
|
|
||||||
@@ -93,6 +94,26 @@ describe("calculateInitiative", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("calculatePf2eInitiative", () => {
|
||||||
|
it("returns perception as both modifier and passive", () => {
|
||||||
|
const result = calculatePf2eInitiative(11);
|
||||||
|
expect(result.modifier).toBe(11);
|
||||||
|
expect(result.passive).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles zero perception", () => {
|
||||||
|
const result = calculatePf2eInitiative(0);
|
||||||
|
expect(result.modifier).toBe(0);
|
||||||
|
expect(result.passive).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles negative perception", () => {
|
||||||
|
const result = calculatePf2eInitiative(-2);
|
||||||
|
expect(result.modifier).toBe(-2);
|
||||||
|
expect(result.passive).toBe(-2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("formatInitiativeModifier", () => {
|
describe("formatInitiativeModifier", () => {
|
||||||
it("formats positive modifier with plus sign", () => {
|
it("formats positive modifier with plus sign", () => {
|
||||||
expect(formatInitiativeModifier(7)).toBe("+7");
|
expect(formatInitiativeModifier(7)).toBe("+7");
|
||||||
|
|||||||
237
packages/domain/src/__tests__/persistent-damage.test.ts
Normal file
237
packages/domain/src/__tests__/persistent-damage.test.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
addPersistentDamage,
|
||||||
|
type PersistentDamageType,
|
||||||
|
removePersistentDamage,
|
||||||
|
} from "../persistent-damage.js";
|
||||||
|
import type { Encounter } from "../types.js";
|
||||||
|
import { combatantId } from "../types.js";
|
||||||
|
|
||||||
|
const goblinId = combatantId("goblin-1");
|
||||||
|
|
||||||
|
function buildEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||||
|
return {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
...overrides.combatants?.[0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: overrides.activeIndex ?? 0,
|
||||||
|
roundNumber: overrides.roundNumber ?? 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("addPersistentDamage", () => {
|
||||||
|
it("adds persistent fire damage to combatant", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
const target = result.encounter.combatants[0];
|
||||||
|
expect(target.persistentDamage).toEqual([{ type: "fire", formula: "2d6" }]);
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{
|
||||||
|
type: "PersistentDamageAdded",
|
||||||
|
combatantId: goblinId,
|
||||||
|
damageType: "fire",
|
||||||
|
formula: "2d6",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces existing entry of same type with new formula", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", "3d6");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||||
|
{ type: "fire", formula: "3d6" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows multiple different damage types", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "bleed", "1d4");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts entries by definition order", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [{ type: "cold", formula: "1d6" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
const types = result.encounter.combatants[0].persistentDamage?.map(
|
||||||
|
(e) => e.type,
|
||||||
|
);
|
||||||
|
expect(types).toEqual(["fire", "cold"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for empty formula", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", " ");
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("empty-formula");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown damage type", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(
|
||||||
|
encounter,
|
||||||
|
goblinId,
|
||||||
|
"radiant" as PersistentDamageType,
|
||||||
|
"2d6",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("unknown-damage-type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(
|
||||||
|
encounter,
|
||||||
|
combatantId("nonexistent"),
|
||||||
|
"fire",
|
||||||
|
"2d6",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("combatant-not-found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims formula whitespace", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", " 2d6 ");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage?.[0].formula).toBe(
|
||||||
|
"2d6",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate input encounter", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const originalCombatants = encounter.combatants;
|
||||||
|
addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||||
|
|
||||||
|
expect(encounter.combatants).toBe(originalCombatants);
|
||||||
|
expect(encounter.combatants[0].persistentDamage).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removePersistentDamage", () => {
|
||||||
|
it("removes existing persistent damage entry", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
]);
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{
|
||||||
|
type: "PersistentDamageRemoved",
|
||||||
|
combatantId: goblinId,
|
||||||
|
damageType: "fire",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets persistentDamage to undefined when last entry removed", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error when damage type not active", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("persistent-damage-not-active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = removePersistentDamage(
|
||||||
|
encounter,
|
||||||
|
combatantId("nonexistent"),
|
||||||
|
"fire",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("combatant-not-found");
|
||||||
|
});
|
||||||
|
});
|
||||||
270
packages/domain/src/__tests__/pf2e-adjustments.test.ts
Normal file
270
packages/domain/src/__tests__/pf2e-adjustments.test.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Pf2eCreature } from "../creature-types.js";
|
||||||
|
import { creatureId } from "../creature-types.js";
|
||||||
|
import {
|
||||||
|
acDelta,
|
||||||
|
adjustedLevel,
|
||||||
|
applyPf2eAdjustment,
|
||||||
|
hpDelta,
|
||||||
|
modDelta,
|
||||||
|
} from "../pf2e-adjustments.js";
|
||||||
|
|
||||||
|
describe("adjustedLevel", () => {
|
||||||
|
it("elite on level 5 → 6", () => {
|
||||||
|
expect(adjustedLevel(5, "elite")).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("elite on level 0 → 2 (double bump)", () => {
|
||||||
|
expect(adjustedLevel(0, "elite")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("elite on level −1 → 1 (double bump)", () => {
|
||||||
|
expect(adjustedLevel(-1, "elite")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weak on level 5 → 4", () => {
|
||||||
|
expect(adjustedLevel(5, "weak")).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weak on level 1 → −1 (double drop)", () => {
|
||||||
|
expect(adjustedLevel(1, "weak")).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weak on level 0 → −1", () => {
|
||||||
|
expect(adjustedLevel(0, "weak")).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hpDelta", () => {
|
||||||
|
it("level 1 elite → +10", () => {
|
||||||
|
expect(hpDelta(1, "elite")).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("level 1 weak → −10", () => {
|
||||||
|
expect(hpDelta(1, "weak")).toBe(-10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("level 3 elite → +15", () => {
|
||||||
|
expect(hpDelta(3, "elite")).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("level 3 weak → −15", () => {
|
||||||
|
expect(hpDelta(3, "weak")).toBe(-15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("level 10 elite → +20", () => {
|
||||||
|
expect(hpDelta(10, "elite")).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("level 10 weak → −20", () => {
|
||||||
|
expect(hpDelta(10, "weak")).toBe(-20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("level 25 elite → +30", () => {
|
||||||
|
expect(hpDelta(25, "elite")).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("level 25 weak → −30", () => {
|
||||||
|
expect(hpDelta(25, "weak")).toBe(-30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("acDelta", () => {
|
||||||
|
it("elite → +2", () => {
|
||||||
|
expect(acDelta("elite")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weak → −2", () => {
|
||||||
|
expect(acDelta("weak")).toBe(-2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("modDelta", () => {
|
||||||
|
it("elite → +2", () => {
|
||||||
|
expect(modDelta("elite")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weak → −2", () => {
|
||||||
|
expect(modDelta("weak")).toBe(-2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function baseCreature(overrides?: Partial<Pf2eCreature>): Pf2eCreature {
|
||||||
|
return {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("test-creature"),
|
||||||
|
name: "Test Creature",
|
||||||
|
source: "test-source",
|
||||||
|
sourceDisplayName: "Test Source",
|
||||||
|
level: 5,
|
||||||
|
traits: ["humanoid"],
|
||||||
|
perception: 12,
|
||||||
|
skills: "Athletics +14",
|
||||||
|
abilityMods: {
|
||||||
|
str: 4,
|
||||||
|
dex: 2,
|
||||||
|
con: 3,
|
||||||
|
int: 0,
|
||||||
|
wis: 1,
|
||||||
|
cha: -1,
|
||||||
|
},
|
||||||
|
ac: 22,
|
||||||
|
saveFort: 14,
|
||||||
|
saveRef: 11,
|
||||||
|
saveWill: 9,
|
||||||
|
hp: 75,
|
||||||
|
speed: "25 feet",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("applyPf2eAdjustment", () => {
|
||||||
|
it("adjusts all numeric stats for elite", () => {
|
||||||
|
const creature = baseCreature();
|
||||||
|
const result = applyPf2eAdjustment(creature, "elite");
|
||||||
|
|
||||||
|
expect(result.level).toBe(6);
|
||||||
|
expect(result.ac).toBe(24);
|
||||||
|
expect(result.hp).toBe(95); // 75 + 20 (level 5 bracket)
|
||||||
|
expect(result.perception).toBe(14);
|
||||||
|
expect(result.saveFort).toBe(16);
|
||||||
|
expect(result.saveRef).toBe(13);
|
||||||
|
expect(result.saveWill).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts all numeric stats for weak", () => {
|
||||||
|
const creature = baseCreature();
|
||||||
|
const result = applyPf2eAdjustment(creature, "weak");
|
||||||
|
|
||||||
|
expect(result.level).toBe(4);
|
||||||
|
expect(result.ac).toBe(20);
|
||||||
|
expect(result.hp).toBe(55); // 75 - 20 (level 5 bracket)
|
||||||
|
expect(result.perception).toBe(10);
|
||||||
|
expect(result.saveFort).toBe(12);
|
||||||
|
expect(result.saveRef).toBe(9);
|
||||||
|
expect(result.saveWill).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts attack bonuses and damage", () => {
|
||||||
|
const creature = baseCreature({
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "Melee",
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: "+15 [+10/+5] (agile), 2d12+7 piercing plus Grab",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyPf2eAdjustment(creature, "elite");
|
||||||
|
const text = result.attacks?.[0].segments[0];
|
||||||
|
expect(text).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: "+17 [+12/+7] (agile), 2d12+9 piercing plus Grab",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts attack damage for weak", () => {
|
||||||
|
const creature = baseCreature({
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "Melee",
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: "+15 (agile), 2d12+7 piercing plus Grab",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyPf2eAdjustment(creature, "weak");
|
||||||
|
const text = result.attacks?.[0].segments[0];
|
||||||
|
expect(text).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: "+13 (agile), 2d12+5 piercing plus Grab",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles damage bonus becoming zero", () => {
|
||||||
|
const creature = baseCreature({
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "Melee",
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [{ type: "text", value: "+10, 1d4+2 slashing" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyPf2eAdjustment(creature, "weak");
|
||||||
|
const text = result.attacks?.[0].segments[0];
|
||||||
|
expect(text).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: "+8, 1d4 slashing",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles damage bonus becoming negative", () => {
|
||||||
|
const creature = baseCreature({
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "Melee",
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [{ type: "text", value: "+10, 1d4+1 slashing" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyPf2eAdjustment(creature, "weak");
|
||||||
|
const text = result.attacks?.[0].segments[0];
|
||||||
|
expect(text).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: "+8, 1d4-1 slashing",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not modify non-attack abilities", () => {
|
||||||
|
const creature = baseCreature({
|
||||||
|
abilitiesTop: [
|
||||||
|
{
|
||||||
|
name: "Darkvision",
|
||||||
|
segments: [{ type: "text", value: "Can see in darkness." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyPf2eAdjustment(creature, "elite");
|
||||||
|
expect(result.abilitiesTop).toEqual(creature.abilitiesTop);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves non-text segments in attacks", () => {
|
||||||
|
const creature = baseCreature({
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "Melee",
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "list",
|
||||||
|
items: [{ text: "some list item" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyPf2eAdjustment(creature, "elite");
|
||||||
|
expect(result.attacks?.[0].segments[0]).toEqual({
|
||||||
|
type: "list",
|
||||||
|
items: [{ text: "some list item" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
99
packages/domain/src/__tests__/recall-knowledge.test.ts
Normal file
99
packages/domain/src/__tests__/recall-knowledge.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { recallKnowledge } from "../recall-knowledge.js";
|
||||||
|
|
||||||
|
describe("recallKnowledge", () => {
|
||||||
|
it("returns null when no type trait is recognized", () => {
|
||||||
|
expect(recallKnowledge(5, ["small", "goblin"])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates DC for a common creature from the DC-by-level table", () => {
|
||||||
|
const result = recallKnowledge(5, ["humanoid"]);
|
||||||
|
expect(result).toEqual({ dc: 20, type: "humanoid", skills: ["Society"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates DC for level -1", () => {
|
||||||
|
const result = recallKnowledge(-1, ["humanoid"]);
|
||||||
|
expect(result).toEqual({ dc: 13, type: "humanoid", skills: ["Society"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates DC for level 0", () => {
|
||||||
|
const result = recallKnowledge(0, ["animal"]);
|
||||||
|
expect(result).toEqual({ dc: 14, type: "animal", skills: ["Nature"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates DC for level 25 (max table entry)", () => {
|
||||||
|
const result = recallKnowledge(25, ["dragon"]);
|
||||||
|
expect(result).toEqual({ dc: 50, type: "dragon", skills: ["Arcana"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps DC for levels beyond the table", () => {
|
||||||
|
const result = recallKnowledge(30, ["dragon"]);
|
||||||
|
expect(result).toEqual({ dc: 50, type: "dragon", skills: ["Arcana"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts DC for uncommon rarity (+2)", () => {
|
||||||
|
const result = recallKnowledge(5, ["uncommon", "medium", "undead"]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
dc: 22,
|
||||||
|
type: "undead",
|
||||||
|
skills: ["Religion"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts DC for rare rarity (+5)", () => {
|
||||||
|
const result = recallKnowledge(5, ["rare", "large", "dragon"]);
|
||||||
|
expect(result).toEqual({ dc: 25, type: "dragon", skills: ["Arcana"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts DC for unique rarity (+10)", () => {
|
||||||
|
const result = recallKnowledge(5, ["unique", "medium", "humanoid"]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
dc: 30,
|
||||||
|
type: "humanoid",
|
||||||
|
skills: ["Society"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns multiple skills for beast type", () => {
|
||||||
|
const result = recallKnowledge(3, ["beast"]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
dc: 18,
|
||||||
|
type: "beast",
|
||||||
|
skills: ["Arcana", "Nature"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns multiple skills for construct type", () => {
|
||||||
|
const result = recallKnowledge(1, ["construct"]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
dc: 15,
|
||||||
|
type: "construct",
|
||||||
|
skills: ["Arcana", "Crafting"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches type traits case-insensitively", () => {
|
||||||
|
const result = recallKnowledge(5, ["Humanoid"]);
|
||||||
|
expect(result).toEqual({ dc: 20, type: "Humanoid", skills: ["Society"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the first matching type trait when multiple are present", () => {
|
||||||
|
const result = recallKnowledge(7, ["large", "monitor", "protean"]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
dc: 23,
|
||||||
|
type: "monitor",
|
||||||
|
skills: ["Religion"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves original trait casing in the returned type", () => {
|
||||||
|
const result = recallKnowledge(1, ["Fey"]);
|
||||||
|
expect(result?.type).toBe("Fey");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores common rarity (no adjustment)", () => {
|
||||||
|
// "common" is not included in traits by the normalization pipeline
|
||||||
|
const result = recallKnowledge(5, ["medium", "humanoid"]);
|
||||||
|
expect(result?.dc).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ describe("rehydrateCombatant", () => {
|
|||||||
expect(result?.maxHp).toBe(7);
|
expect(result?.maxHp).toBe(7);
|
||||||
expect(result?.currentHp).toBe(5);
|
expect(result?.currentHp).toBe(5);
|
||||||
expect(result?.tempHp).toBe(3);
|
expect(result?.tempHp).toBe(3);
|
||||||
expect(result?.conditions).toEqual(["poisoned"]);
|
expect(result?.conditions).toEqual([{ id: "poisoned" }]);
|
||||||
expect(result?.isConcentrating).toBe(true);
|
expect(result?.isConcentrating).toBe(true);
|
||||||
expect(result?.creatureId).toBe("creature-goblin");
|
expect(result?.creatureId).toBe("creature-goblin");
|
||||||
expect(result?.color).toBe("red");
|
expect(result?.color).toBe("red");
|
||||||
@@ -165,7 +165,45 @@ describe("rehydrateCombatant", () => {
|
|||||||
...minimalCombatant(),
|
...minimalCombatant(),
|
||||||
conditions: ["poisoned", "fake", "blinded"],
|
conditions: ["poisoned", "fake", "blinded"],
|
||||||
});
|
});
|
||||||
expect(result?.conditions).toEqual(["poisoned", "blinded"]);
|
expect(result?.conditions).toEqual([
|
||||||
|
{ id: "poisoned" },
|
||||||
|
{ id: "blinded" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts old bare string format to ConditionEntry", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
conditions: ["blinded", "prone"],
|
||||||
|
});
|
||||||
|
expect(result?.conditions).toEqual([{ id: "blinded" }, { id: "prone" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through new ConditionEntry format with values", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
conditions: [{ id: "blinded" }, { id: "frightened", value: 2 }],
|
||||||
|
});
|
||||||
|
expect(result?.conditions).toEqual([
|
||||||
|
{ id: "blinded" },
|
||||||
|
{ id: "frightened", value: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed old and new format entries", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
conditions: ["blinded", { id: "prone" }],
|
||||||
|
});
|
||||||
|
expect(result?.conditions).toEqual([{ id: "blinded" }, { id: "prone" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops ConditionEntry with invalid value", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
conditions: [{ id: "blinded", value: -1 }],
|
||||||
|
});
|
||||||
|
expect(result?.conditions).toEqual([{ id: "blinded" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops invalid color — keeps combatant", () => {
|
it("drops invalid color — keeps combatant", () => {
|
||||||
@@ -241,6 +279,74 @@ describe("rehydrateCombatant", () => {
|
|||||||
expect(result?.cr).toBeUndefined();
|
expect(result?.cr).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves valid side field", () => {
|
||||||
|
for (const side of ["party", "enemy"]) {
|
||||||
|
const result = rehydrateCombatant({ ...minimalCombatant(), side });
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.side).toBe(side);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid side field", () => {
|
||||||
|
for (const side of ["ally", "", 42, null, true]) {
|
||||||
|
const result = rehydrateCombatant({ ...minimalCombatant(), side });
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.side).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combatant without side rehydrates as before", () => {
|
||||||
|
const result = rehydrateCombatant(minimalCombatant());
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.side).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves valid persistent damage entries", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
persistentDamage: [
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result?.persistentDamage).toEqual([
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out invalid persistent damage entries", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
persistentDamage: [
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "radiant", formula: "1d4" },
|
||||||
|
{ type: "bleed", formula: "" },
|
||||||
|
{ type: "acid" },
|
||||||
|
{ formula: "1d6" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result?.persistentDamage).toEqual([
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined persistentDamage for non-array value", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
persistentDamage: "fire",
|
||||||
|
});
|
||||||
|
expect(result?.persistentDamage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined persistentDamage for empty array", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
persistentDamage: [],
|
||||||
|
});
|
||||||
|
expect(result?.persistentDamage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("drops invalid tempHp — keeps combatant", () => {
|
it("drops invalid tempHp — keeps combatant", () => {
|
||||||
for (const tempHp of [-1, 1.5, "3"]) {
|
for (const tempHp of [-1, 1.5, "3"]) {
|
||||||
const result = rehydrateCombatant({
|
const result = rehydrateCombatant({
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user