Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e62e54274 | ||
|
|
12a089dfd7 | ||
|
|
65e4db153b | ||
|
|
8dbff66ce1 | ||
|
|
e62c49434c | ||
|
|
8f6eebc43b | ||
|
|
817cfddabc | ||
|
|
94e1806112 | ||
|
|
30e7ed4121 | ||
|
|
5540baf14c | ||
|
|
1ae9e12cff | ||
|
|
2c643cc98b |
52
CLAUDE.md
52
CLAUDE.md
@@ -69,12 +69,62 @@ 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 `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios from specs to individual `it()` blocks.
|
- **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.
|
||||||
|
|
||||||
For component prop rules, export format compatibility, and ADRs, see [`docs/conventions.md`](docs/conventions.md).
|
For component prop rules, export format compatibility, and ADRs, see [`docs/conventions.md`](docs/conventions.md).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Philosophy
|
||||||
|
|
||||||
|
Test **user-visible behavior**, not implementation details. A good test answers "does this feature work?" not "does this internal function get called?"
|
||||||
|
|
||||||
|
### Adapter Injection
|
||||||
|
|
||||||
|
Adapters (storage, cache, browser APIs) are provided via `AdapterContext`. Production wires real implementations; tests wire in-memory implementations. This means:
|
||||||
|
- No `vi.mock()` for adapter or persistence modules
|
||||||
|
- Tests control adapter behavior by configuring the in-memory implementation
|
||||||
|
- Type changes in adapter interfaces are caught at compile time
|
||||||
|
|
||||||
|
### Per-Layer Approach
|
||||||
|
|
||||||
|
| Layer | How to test |
|
||||||
|
|---|---|
|
||||||
|
| Domain (`packages/domain`) | Pure unit tests, no mocks, test invariants and acceptance scenarios |
|
||||||
|
| Application (`packages/application`) | Mock port interfaces only, use real domain logic |
|
||||||
|
| Hooks (context-wrapped) | Test via `renderHook` with `AllProviders` wrapping in-memory adapters |
|
||||||
|
| Hooks (component-specific) | Test through the component that uses them |
|
||||||
|
| Components | Render with `AllProviders`, use in-memory adapters, use `userEvent` for interactions |
|
||||||
|
|
||||||
|
### Test Data
|
||||||
|
|
||||||
|
Use factory functions from `apps/web/src/__tests__/factories/` to construct domain objects. Each factory provides sensible defaults overridden via `Partial<T>`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { buildEncounter } from "../../__tests__/factories/build-encounter.js";
|
||||||
|
import { buildCombatant } from "../../__tests__/factories/build-combatant.js";
|
||||||
|
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "Goblin" })],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Add new factory files as needed (one per domain type). Don't inline test data construction — use factories so type changes are caught at compile time.
|
||||||
|
|
||||||
|
### Anti-Patterns
|
||||||
|
|
||||||
|
- **`vi.mock()` for adapters**: Use in-memory adapter implementations via `AdapterContext` instead
|
||||||
|
- **Mocking contexts**: Use `AllProviders` and drive state through real hooks instead of `vi.mock("../../contexts/...")`. Exception: context mocks are acceptable when the component under test requires specific state machine states that cannot be reached through adapter configuration alone — document the reason in a comment at the top of the test file.
|
||||||
|
- **Stubbing child components**: Render real children; stub only if the child has heavy I/O that can't be mocked at the adapter level
|
||||||
|
- **Asserting mock call counts**: Prefer asserting what the user sees (`screen.getByText(...)`) over `expect(mockFn).toHaveBeenCalledWith(...)`
|
||||||
|
- **Testing internal state**: Don't assert `result.current.suggestionIndex === 0`; assert the first suggestion is highlighted
|
||||||
|
- **Assertion-free tests**: Every `it()` block must contain at least one `expect()`. Tests that render without asserting inflate coverage without catching bugs.
|
||||||
|
|
||||||
## Self-Review Checklist
|
## Self-Review Checklist
|
||||||
|
|
||||||
Before finishing a change, consider:
|
Before finishing a change, consider:
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
120
apps/web/src/__tests__/adapters/in-memory-adapters.ts
Normal file
120
apps/web/src/__tests__/adapters/in-memory-adapters.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
type AnyCreature,
|
||||||
|
type CreatureId,
|
||||||
|
EMPTY_UNDO_REDO_STATE,
|
||||||
|
type Encounter,
|
||||||
|
type PlayerCharacter,
|
||||||
|
type UndoRedoState,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { Adapters } from "../../contexts/adapter-context.js";
|
||||||
|
|
||||||
|
export function createTestAdapters(options?: {
|
||||||
|
encounter?: Encounter | null;
|
||||||
|
undoRedoState?: UndoRedoState;
|
||||||
|
playerCharacters?: PlayerCharacter[];
|
||||||
|
creatures?: Map<CreatureId, AnyCreature>;
|
||||||
|
sources?: Map<
|
||||||
|
string,
|
||||||
|
{ displayName: string; creatures: AnyCreature[]; cachedAt: number }
|
||||||
|
>;
|
||||||
|
}): Adapters {
|
||||||
|
let storedEncounter = options?.encounter ?? null;
|
||||||
|
let storedUndoRedo = options?.undoRedoState ?? EMPTY_UNDO_REDO_STATE;
|
||||||
|
let storedPCs = options?.playerCharacters ?? [];
|
||||||
|
const sourceStore =
|
||||||
|
options?.sources ??
|
||||||
|
new Map<
|
||||||
|
string,
|
||||||
|
{ displayName: string; creatures: AnyCreature[]; cachedAt: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Pre-populate sourceStore from creatures map if provided
|
||||||
|
if (options?.creatures && !options?.sources) {
|
||||||
|
// No-op: creatures are accessed directly from the map
|
||||||
|
}
|
||||||
|
|
||||||
|
const creatureMap = options?.creatures ?? new Map<CreatureId, AnyCreature>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounterPersistence: {
|
||||||
|
load: () => storedEncounter,
|
||||||
|
save: (e) => {
|
||||||
|
storedEncounter = e;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undoRedoPersistence: {
|
||||||
|
load: () => storedUndoRedo,
|
||||||
|
save: (state) => {
|
||||||
|
storedUndoRedo = state;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
playerCharacterPersistence: {
|
||||||
|
load: () => [...storedPCs],
|
||||||
|
save: (pcs) => {
|
||||||
|
storedPCs = pcs;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bestiaryCache: {
|
||||||
|
cacheSource(system, sourceCode, displayName, creatures) {
|
||||||
|
const key = `${system}:${sourceCode}`;
|
||||||
|
sourceStore.set(key, {
|
||||||
|
displayName,
|
||||||
|
creatures,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
});
|
||||||
|
for (const c of creatures) {
|
||||||
|
creatureMap.set(c.id, c);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
isSourceCached(system, sourceCode) {
|
||||||
|
return Promise.resolve(sourceStore.has(`${system}:${sourceCode}`));
|
||||||
|
},
|
||||||
|
getCachedSources(system) {
|
||||||
|
return Promise.resolve(
|
||||||
|
[...sourceStore.entries()]
|
||||||
|
.filter(([key]) => !system || key.startsWith(`${system}:`))
|
||||||
|
.map(([key, info]) => ({
|
||||||
|
sourceCode: key.includes(":")
|
||||||
|
? key.slice(key.indexOf(":") + 1)
|
||||||
|
: key,
|
||||||
|
displayName: info.displayName,
|
||||||
|
creatureCount: info.creatures.length,
|
||||||
|
cachedAt: info.cachedAt,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
clearSource(system, sourceCode) {
|
||||||
|
sourceStore.delete(`${system}:${sourceCode}`);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
clearAll() {
|
||||||
|
sourceStore.clear();
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
loadAllCachedCreatures() {
|
||||||
|
return Promise.resolve(new Map(creatureMap));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bestiaryIndex: {
|
||||||
|
loadIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
getDefaultFetchUrl: (sourceCode, baseUrl) => {
|
||||||
|
const filename = `bestiary-${sourceCode.toLowerCase()}.json`;
|
||||||
|
if (baseUrl !== undefined) {
|
||||||
|
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||||
|
return `${normalized}${filename}`;
|
||||||
|
}
|
||||||
|
return `https://example.com/${filename}`;
|
||||||
|
},
|
||||||
|
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||||
|
},
|
||||||
|
pf2eBestiaryIndex: {
|
||||||
|
loadIndex: () => ({ sources: {}, creatures: [] }),
|
||||||
|
getAllSourceCodes: () => [],
|
||||||
|
getDefaultFetchUrl: (sourceCode) =>
|
||||||
|
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
|
||||||
|
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,34 +7,6 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|||||||
import { App } from "../App.js";
|
import { App } from "../App.js";
|
||||||
import { AllProviders } from "./test-providers.js";
|
import { AllProviders } from "./test-providers.js";
|
||||||
|
|
||||||
// Mock persistence — no localStorage interaction
|
|
||||||
vi.mock("../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: () => null,
|
|
||||||
saveEncounter: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../persistence/player-character-storage.js", () => ({
|
|
||||||
loadPlayerCharacters: () => [],
|
|
||||||
savePlayerCharacters: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock bestiary — no IndexedDB or JSON index
|
|
||||||
vi.mock("../adapters/bestiary-cache.js", () => ({
|
|
||||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
|
||||||
isSourceCached: () => Promise.resolve(false),
|
|
||||||
cacheSource: () => Promise.resolve(),
|
|
||||||
getCachedSources: () => Promise.resolve([]),
|
|
||||||
clearSource: () => Promise.resolve(),
|
|
||||||
clearAll: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// DOM API stubs — jsdom doesn't implement these
|
// DOM API stubs — jsdom doesn't implement these
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(globalThis, "matchMedia", {
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
getAllSourceCodes,
|
|
||||||
getDefaultFetchUrl,
|
|
||||||
} from "../adapters/bestiary-index-adapter.js";
|
|
||||||
|
|
||||||
describe("getAllSourceCodes", () => {
|
|
||||||
it("returns all keys from the index sources object", () => {
|
|
||||||
const codes = getAllSourceCodes();
|
|
||||||
expect(codes.length).toBeGreaterThan(0);
|
|
||||||
expect(Array.isArray(codes)).toBe(true);
|
|
||||||
for (const code of codes) {
|
|
||||||
expect(typeof code).toBe("string");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getDefaultFetchUrl", () => {
|
|
||||||
it("returns the default URL when no baseUrl is provided", () => {
|
|
||||||
const url = getDefaultFetchUrl("XMM");
|
|
||||||
expect(url).toBe(
|
|
||||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-xmm.json",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("constructs URL from baseUrl with trailing slash", () => {
|
|
||||||
const url = getDefaultFetchUrl("PHB", "https://example.com/data/");
|
|
||||||
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes baseUrl without trailing slash", () => {
|
|
||||||
const url = getDefaultFetchUrl("PHB", "https://example.com/data");
|
|
||||||
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lowercases the source code in the filename", () => {
|
|
||||||
const url = getDefaultFetchUrl("MM", "https://example.com/");
|
|
||||||
expect(url).toBe("https://example.com/bestiary-mm.json");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -209,6 +209,82 @@ describe("round-trip: export then import", () => {
|
|||||||
expect(imported.playerCharacters).toEqual(bundle.playerCharacters);
|
expect(imported.playerCharacters).toEqual(bundle.playerCharacters);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trips a combatant with cr field", () => {
|
||||||
|
const encounterWithCr: Encounter = {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Custom Thug",
|
||||||
|
cr: "2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const emptyUndoRedo: UndoRedoState = {
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
};
|
||||||
|
const bundle = assembleExportBundle(encounterWithCr, 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].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: [],
|
||||||
|
|||||||
12
apps/web/src/__tests__/factories/build-combatant.ts
Normal file
12
apps/web/src/__tests__/factories/build-combatant.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Combatant } from "@initiative/domain";
|
||||||
|
import { combatantId } from "@initiative/domain";
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
export function buildCombatant(overrides?: Partial<Combatant>): Combatant {
|
||||||
|
return {
|
||||||
|
id: combatantId(`c-${++counter}`),
|
||||||
|
name: "Combatant",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
26
apps/web/src/__tests__/factories/build-creature.ts
Normal file
26
apps/web/src/__tests__/factories/build-creature.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Creature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
export function buildCreature(overrides?: Partial<Creature>): Creature {
|
||||||
|
const id = ++counter;
|
||||||
|
return {
|
||||||
|
id: creatureId(`creature-${id}`),
|
||||||
|
name: `Creature ${id}`,
|
||||||
|
source: "srd",
|
||||||
|
sourceDisplayName: "SRD",
|
||||||
|
size: "Medium",
|
||||||
|
type: "humanoid",
|
||||||
|
alignment: "neutral",
|
||||||
|
ac: 13,
|
||||||
|
hp: { average: 7, formula: "2d6" },
|
||||||
|
speed: "30 ft.",
|
||||||
|
abilities: { str: 10, dex: 14, con: 10, int: 10, wis: 10, cha: 10 },
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
proficiencyBonus: 2,
|
||||||
|
passive: 10,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
10
apps/web/src/__tests__/factories/build-encounter.ts
Normal file
10
apps/web/src/__tests__/factories/build-encounter.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Encounter } from "@initiative/domain";
|
||||||
|
|
||||||
|
export function buildEncounter(overrides?: Partial<Encounter>): Encounter {
|
||||||
|
return {
|
||||||
|
combatants: [],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
3
apps/web/src/__tests__/factories/index.ts
Normal file
3
apps/web/src/__tests__/factories/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { buildCombatant } from "./build-combatant.js";
|
||||||
|
export { buildCreature } from "./build-creature.js";
|
||||||
|
export { buildEncounter } from "./build-encounter.js";
|
||||||
@@ -5,7 +5,9 @@ import type { Creature, CreatureId } from "@initiative/domain";
|
|||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
// Mock the context modules
|
// Uses context mocks because StatBlockPanel requires fine-grained control over
|
||||||
|
// panel state (collapsed/expanded, pinned/unpinned, wide/narrow desktop) that
|
||||||
|
// would need extensive setup to drive through real providers.
|
||||||
vi.mock("../contexts/side-panel-context.js", () => ({
|
vi.mock("../contexts/side-panel-context.js", () => ({
|
||||||
useSidePanelContext: vi.fn(),
|
useSidePanelContext: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -14,14 +16,6 @@ vi.mock("../contexts/bestiary-context.js", () => ({
|
|||||||
useBestiaryContext: vi.fn(),
|
useBestiaryContext: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock adapters to avoid IndexedDB
|
|
||||||
vi.mock("../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
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 { useSidePanelContext } from "../contexts/side-panel-context.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import type { Adapters } from "../contexts/adapter-context.js";
|
||||||
|
import { AdapterProvider } from "../contexts/adapter-context.js";
|
||||||
import {
|
import {
|
||||||
BestiaryProvider,
|
BestiaryProvider,
|
||||||
BulkImportProvider,
|
BulkImportProvider,
|
||||||
@@ -9,9 +11,18 @@ import {
|
|||||||
SidePanelProvider,
|
SidePanelProvider,
|
||||||
ThemeProvider,
|
ThemeProvider,
|
||||||
} from "../contexts/index.js";
|
} from "../contexts/index.js";
|
||||||
|
import { createTestAdapters } from "./adapters/in-memory-adapters.js";
|
||||||
|
|
||||||
export function AllProviders({ children }: { children: ReactNode }) {
|
export function AllProviders({
|
||||||
|
adapters,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
adapters?: Adapters;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const resolved = adapters ?? createTestAdapters();
|
||||||
return (
|
return (
|
||||||
|
<AdapterProvider adapters={resolved}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<RulesEditionProvider>
|
<RulesEditionProvider>
|
||||||
<EncounterProvider>
|
<EncounterProvider>
|
||||||
@@ -19,7 +30,9 @@ export function AllProviders({ children }: { children: ReactNode }) {
|
|||||||
<PlayerCharactersProvider>
|
<PlayerCharactersProvider>
|
||||||
<BulkImportProvider>
|
<BulkImportProvider>
|
||||||
<SidePanelProvider>
|
<SidePanelProvider>
|
||||||
<InitiativeRollsProvider>{children}</InitiativeRollsProvider>
|
<InitiativeRollsProvider>
|
||||||
|
{children}
|
||||||
|
</InitiativeRollsProvider>
|
||||||
</SidePanelProvider>
|
</SidePanelProvider>
|
||||||
</BulkImportProvider>
|
</BulkImportProvider>
|
||||||
</PlayerCharactersProvider>
|
</PlayerCharactersProvider>
|
||||||
@@ -27,5 +40,6 @@ export function AllProviders({ children }: { children: ReactNode }) {
|
|||||||
</EncounterProvider>
|
</EncounterProvider>
|
||||||
</RulesEditionProvider>
|
</RulesEditionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</AdapterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
@@ -333,9 +348,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 +383,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." },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { Creature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock idb to reject — simulates IndexedDB unavailable.
|
||||||
|
// This must be a separate file from bestiary-cache.test.ts because the
|
||||||
|
// module caches the db connection in a singleton; once openDB succeeds
|
||||||
|
// in one test, the fallback path is unreachable.
|
||||||
|
vi.mock("idb", () => ({
|
||||||
|
openDB: vi.fn().mockRejectedValue(new Error("IndexedDB unavailable")),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
cacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
getCachedSources,
|
||||||
|
clearSource,
|
||||||
|
clearAll,
|
||||||
|
loadAllCachedCreatures,
|
||||||
|
} = await import("../bestiary-cache.js");
|
||||||
|
|
||||||
|
function makeCreature(id: string, name: string): Creature {
|
||||||
|
return {
|
||||||
|
id: creatureId(id),
|
||||||
|
name,
|
||||||
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
alignment: "neutral evil",
|
||||||
|
ac: 15,
|
||||||
|
hp: { average: 7, formula: "2d6" },
|
||||||
|
speed: "30 ft.",
|
||||||
|
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
proficiencyBonus: 2,
|
||||||
|
passive: 9,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bestiary-cache fallback (IndexedDB unavailable)", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await clearAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cacheSource falls back to in-memory store", async () => {
|
||||||
|
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", creatures);
|
||||||
|
|
||||||
|
expect(await isSourceCached("dnd", "MM")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isSourceCached returns false for uncached source", async () => {
|
||||||
|
expect(await isSourceCached("dnd", "XGE")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCachedSources returns sources from in-memory store", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", [
|
||||||
|
makeCreature("mm:goblin", "Goblin"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toHaveLength(1);
|
||||||
|
expect(sources[0].sourceCode).toBe("MM");
|
||||||
|
expect(sources[0].creatureCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loadAllCachedCreatures assembles creatures from in-memory store", async () => {
|
||||||
|
const goblin = makeCreature("mm:goblin", "Goblin");
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", [goblin]);
|
||||||
|
|
||||||
|
const map = await loadAllCachedCreatures();
|
||||||
|
expect(map.size).toBe(1);
|
||||||
|
expect(map.get(creatureId("mm:goblin"))?.name).toBe("Goblin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearSource removes a single source from in-memory store", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
|
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||||
|
|
||||||
|
await clearSource("dnd", "MM");
|
||||||
|
|
||||||
|
expect(await isSourceCached("dnd", "MM")).toBe(false);
|
||||||
|
expect(await isSourceCached("dnd", "VGM")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearAll removes all data from in-memory store", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
|
await clearAll();
|
||||||
|
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
174
apps/web/src/adapters/__tests__/bestiary-cache.test.ts
Normal file
174
apps/web/src/adapters/__tests__/bestiary-cache.test.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import type { Creature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock idb — the one legitimate use of vi.mock for a third-party I/O library.
|
||||||
|
// We can't use real IndexedDB in jsdom; this tests the cache logic through
|
||||||
|
// all public API methods with an in-memory backing store.
|
||||||
|
const fakeStore = new Map<string, unknown>();
|
||||||
|
|
||||||
|
vi.mock("idb", () => ({
|
||||||
|
openDB: vi.fn().mockResolvedValue({
|
||||||
|
put: vi.fn((_storeName: string, value: unknown) => {
|
||||||
|
const record = value as { sourceCode: string };
|
||||||
|
fakeStore.set(record.sourceCode, value);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
get: vi.fn((_storeName: string, key: string) =>
|
||||||
|
Promise.resolve(fakeStore.get(key)),
|
||||||
|
),
|
||||||
|
getAll: vi.fn((_storeName: string) =>
|
||||||
|
Promise.resolve([...fakeStore.values()]),
|
||||||
|
),
|
||||||
|
delete: vi.fn((_storeName: string, key: string) => {
|
||||||
|
fakeStore.delete(key);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
clear: vi.fn((_storeName: string) => {
|
||||||
|
fakeStore.clear();
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
const {
|
||||||
|
cacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
getCachedSources,
|
||||||
|
clearSource,
|
||||||
|
clearAll,
|
||||||
|
loadAllCachedCreatures,
|
||||||
|
} = await import("../bestiary-cache.js");
|
||||||
|
|
||||||
|
function makeCreature(id: string, name: string): Creature {
|
||||||
|
return {
|
||||||
|
id: creatureId(id),
|
||||||
|
name,
|
||||||
|
source: "MM",
|
||||||
|
sourceDisplayName: "Monster Manual",
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
alignment: "neutral evil",
|
||||||
|
ac: 15,
|
||||||
|
hp: { average: 7, formula: "2d6" },
|
||||||
|
speed: "30 ft.",
|
||||||
|
abilities: { str: 8, dex: 14, con: 10, int: 10, wis: 8, cha: 8 },
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
proficiencyBonus: 2,
|
||||||
|
passive: 9,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bestiary-cache", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fakeStore.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cacheSource", () => {
|
||||||
|
it("stores creatures and metadata", async () => {
|
||||||
|
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", creatures);
|
||||||
|
|
||||||
|
expect(fakeStore.has("dnd:MM")).toBe(true);
|
||||||
|
const record = fakeStore.get("dnd:MM") as {
|
||||||
|
sourceCode: string;
|
||||||
|
displayName: string;
|
||||||
|
creatures: Creature[];
|
||||||
|
creatureCount: number;
|
||||||
|
cachedAt: number;
|
||||||
|
};
|
||||||
|
expect(record.sourceCode).toBe("dnd:MM");
|
||||||
|
expect(record.displayName).toBe("Monster Manual");
|
||||||
|
expect(record.creatures).toHaveLength(1);
|
||||||
|
expect(record.creatureCount).toBe(1);
|
||||||
|
expect(record.cachedAt).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isSourceCached", () => {
|
||||||
|
it("returns false for uncached source", async () => {
|
||||||
|
expect(await isSourceCached("dnd", "XGE")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true after caching", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
|
expect(await isSourceCached("dnd", "MM")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCachedSources", () => {
|
||||||
|
it("returns empty array when no sources cached", async () => {
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns source info with creature counts", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", [
|
||||||
|
makeCreature("mm:goblin", "Goblin"),
|
||||||
|
makeCreature("mm:orc", "Orc"),
|
||||||
|
]);
|
||||||
|
await cacheSource("dnd", "VGM", "Volo's Guide", [
|
||||||
|
makeCreature("vgm:flind", "Flind"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toHaveLength(2);
|
||||||
|
|
||||||
|
const mm = sources.find((s) => s.sourceCode === "MM");
|
||||||
|
expect(mm).toBeDefined();
|
||||||
|
expect(mm?.displayName).toBe("Monster Manual");
|
||||||
|
expect(mm?.creatureCount).toBe(2);
|
||||||
|
|
||||||
|
const vgm = sources.find((s) => s.sourceCode === "VGM");
|
||||||
|
expect(vgm?.creatureCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadAllCachedCreatures", () => {
|
||||||
|
it("returns empty map when nothing cached", async () => {
|
||||||
|
const map = await loadAllCachedCreatures();
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assembles creatures from all cached sources", async () => {
|
||||||
|
const goblin = makeCreature("mm:goblin", "Goblin");
|
||||||
|
const orc = makeCreature("mm:orc", "Orc");
|
||||||
|
const flind = makeCreature("vgm:flind", "Flind");
|
||||||
|
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", [goblin, orc]);
|
||||||
|
await cacheSource("dnd", "VGM", "Volo's Guide", [flind]);
|
||||||
|
|
||||||
|
const map = await loadAllCachedCreatures();
|
||||||
|
expect(map.size).toBe(3);
|
||||||
|
expect(map.get(creatureId("mm:goblin"))?.name).toBe("Goblin");
|
||||||
|
expect(map.get(creatureId("mm:orc"))?.name).toBe("Orc");
|
||||||
|
expect(map.get(creatureId("vgm:flind"))?.name).toBe("Flind");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearSource", () => {
|
||||||
|
it("removes a single source", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
|
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||||
|
|
||||||
|
await clearSource("dnd", "MM");
|
||||||
|
|
||||||
|
expect(await isSourceCached("dnd", "MM")).toBe(false);
|
||||||
|
expect(await isSourceCached("dnd", "VGM")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearAll", () => {
|
||||||
|
it("removes all cached data", async () => {
|
||||||
|
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||||
|
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||||
|
|
||||||
|
await clearAll();
|
||||||
|
|
||||||
|
const sources = await getCachedSources();
|
||||||
|
expect(sources).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
107
apps/web/src/adapters/__tests__/bestiary-index-adapter.test.ts
Normal file
107
apps/web/src/adapters/__tests__/bestiary-index-adapter.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getAllSourceCodes,
|
||||||
|
getDefaultFetchUrl,
|
||||||
|
getSourceDisplayName,
|
||||||
|
loadBestiaryIndex,
|
||||||
|
} from "../bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
describe("loadBestiaryIndex", () => {
|
||||||
|
it("returns an object with sources and creatures", () => {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
expect(index.sources).toBeDefined();
|
||||||
|
expect(index.creatures).toBeDefined();
|
||||||
|
expect(Array.isArray(index.creatures)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creatures have the expected shape", () => {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
expect(index.creatures.length).toBeGreaterThan(0);
|
||||||
|
const first = index.creatures[0];
|
||||||
|
expect(first).toHaveProperty("name");
|
||||||
|
expect(first).toHaveProperty("source");
|
||||||
|
expect(first).toHaveProperty("ac");
|
||||||
|
expect(first).toHaveProperty("hp");
|
||||||
|
expect(first).toHaveProperty("dex");
|
||||||
|
expect(first).toHaveProperty("cr");
|
||||||
|
expect(first).toHaveProperty("initiativeProficiency");
|
||||||
|
expect(first).toHaveProperty("size");
|
||||||
|
expect(first).toHaveProperty("type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same cached instance on subsequent calls", () => {
|
||||||
|
const a = loadBestiaryIndex();
|
||||||
|
const b = loadBestiaryIndex();
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sources is a record of source code to display name", () => {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
const entries = Object.entries(index.sources);
|
||||||
|
expect(entries.length).toBeGreaterThan(0);
|
||||||
|
for (const [code, name] of entries) {
|
||||||
|
expect(typeof code).toBe("string");
|
||||||
|
expect(typeof name).toBe("string");
|
||||||
|
expect(code.length).toBeGreaterThan(0);
|
||||||
|
expect(name.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAllSourceCodes", () => {
|
||||||
|
it("returns all keys from the index sources", () => {
|
||||||
|
const codes = getAllSourceCodes();
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
expect(codes).toEqual(Object.keys(index.sources));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only strings", () => {
|
||||||
|
for (const code of getAllSourceCodes()) {
|
||||||
|
expect(typeof code).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDefaultFetchUrl", () => {
|
||||||
|
it("returns default GitHub URL when no baseUrl provided", () => {
|
||||||
|
const url = getDefaultFetchUrl("MM");
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-mm.json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constructs URL from baseUrl with trailing slash", () => {
|
||||||
|
const url = getDefaultFetchUrl("PHB", "https://example.com/data/");
|
||||||
|
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes baseUrl without trailing slash", () => {
|
||||||
|
const url = getDefaultFetchUrl("PHB", "https://example.com/data");
|
||||||
|
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lowercases the source code in the filename", () => {
|
||||||
|
const url = getDefaultFetchUrl("XMM");
|
||||||
|
expect(url).toContain("bestiary-xmm.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies filename override for Plane Shift sources", () => {
|
||||||
|
expect(getDefaultFetchUrl("PSA")).toContain("bestiary-ps-a.json");
|
||||||
|
expect(getDefaultFetchUrl("PSD")).toContain("bestiary-ps-d.json");
|
||||||
|
expect(getDefaultFetchUrl("PSK")).toContain("bestiary-ps-k.json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSourceDisplayName", () => {
|
||||||
|
it("returns display name for a known source", () => {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
const [code, expectedName] = Object.entries(index.sources)[0];
|
||||||
|
expect(getSourceDisplayName(code)).toBe(expectedName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to source code for unknown source", () => {
|
||||||
|
expect(getSourceDisplayName("UNKNOWN_SOURCE_XYZ")).toBe(
|
||||||
|
"UNKNOWN_SOURCE_XYZ",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
200
apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts
Normal file
200
apps/web/src/adapters/__tests__/pf2e-bestiary-adapter.test.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizePf2eBestiary } from "../pf2e-bestiary-adapter.js";
|
||||||
|
|
||||||
|
function minimalCreature(overrides?: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
name: "Test Creature",
|
||||||
|
source: "TST",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("normalizePf2eBestiary", () => {
|
||||||
|
describe("weaknesses formatting", () => {
|
||||||
|
it("formats weakness with numeric amount", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
defenses: {
|
||||||
|
weaknesses: [{ name: "fire", amount: 5 }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.weaknesses).toBe("Fire 5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats weakness without amount (qualitative)", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
defenses: {
|
||||||
|
weaknesses: [{ name: "smoke susceptibility" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.weaknesses).toBe("Smoke susceptibility");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats weakness with note and amount", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
defenses: {
|
||||||
|
weaknesses: [
|
||||||
|
{ name: "cold iron", amount: 5, note: "except daggers" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.weaknesses).toBe("Cold iron 5 (except daggers)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats weakness with note but no amount", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
defenses: {
|
||||||
|
weaknesses: [{ name: "smoke susceptibility", note: "see below" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.weaknesses).toBe("Smoke susceptibility (see below)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no weaknesses", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [minimalCreature({})],
|
||||||
|
});
|
||||||
|
expect(creature.weaknesses).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("senses formatting", () => {
|
||||||
|
it("strips tags and includes type and range", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
senses: [
|
||||||
|
{
|
||||||
|
type: "imprecise",
|
||||||
|
name: "{@ability tremorsense}",
|
||||||
|
range: 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.senses).toBe("Tremorsense (imprecise) 30 feet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats sense with only a name", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
senses: [{ name: "darkvision" }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.senses).toBe("Darkvision");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats sense with name and range but no type", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
senses: [{ name: "scent", range: 60 }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.senses).toBe("Scent 60 feet");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("attack formatting", () => {
|
||||||
|
it("strips angle brackets from traits", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "stinger",
|
||||||
|
range: "Melee",
|
||||||
|
attack: 11,
|
||||||
|
traits: ["deadly <d8>"],
|
||||||
|
damage: "1d6+4 piercing",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const attack = creature.attacks?.[0];
|
||||||
|
expect(attack).toBeDefined();
|
||||||
|
expect(attack?.segments[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "text",
|
||||||
|
value: expect.stringContaining("(deadly d8)"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips angle brackets from reach values in traits", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "tentacle",
|
||||||
|
range: "Melee",
|
||||||
|
attack: 18,
|
||||||
|
traits: ["agile", "chaotic", "magical", "reach <10 feet>"],
|
||||||
|
damage: "2d8+6 piercing",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const attack = creature.attacks?.[0];
|
||||||
|
expect(attack).toBeDefined();
|
||||||
|
expect(attack?.segments[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "text",
|
||||||
|
value: expect.stringContaining(
|
||||||
|
"(agile, chaotic, magical, reach 10 feet)",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resistances formatting", () => {
|
||||||
|
it("formats resistance without amount", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
defenses: {
|
||||||
|
resistances: [{ name: "physical" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.resistances).toBe("Physical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats resistance with amount", () => {
|
||||||
|
const [creature] = normalizePf2eBestiary({
|
||||||
|
creature: [
|
||||||
|
minimalCreature({
|
||||||
|
defenses: {
|
||||||
|
resistances: [{ name: "fire", amount: 10 }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(creature.resistances).toBe("Fire 10");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getAllPf2eSourceCodes,
|
||||||
|
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(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 Pf2eTools GitHub URL with lowercase source code", () => {
|
||||||
|
const url = getDefaultPf2eFetchUrl("B1");
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/creatures-b1.json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPf2eSourceDisplayName", () => {
|
||||||
|
it("returns display name for a known source", () => {
|
||||||
|
expect(getPf2eSourceDisplayName("B1")).toBe("Bestiary");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to source code for unknown source", () => {
|
||||||
|
expect(getPf2eSourceDisplayName("UNKNOWN")).toBe("UNKNOWN");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
LegendaryBlock,
|
LegendaryBlock,
|
||||||
SpellcastingBlock,
|
SpellcastingBlock,
|
||||||
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 +65,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 +266,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 +312,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),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +437,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,25 @@
|
|||||||
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;
|
const DB_VERSION = 4;
|
||||||
|
|
||||||
export 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 +28,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 +44,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 +64,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 +148,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[];
|
||||||
|
|||||||
350
apps/web/src/adapters/pf2e-bestiary-adapter.ts
Normal file
350
apps/web/src/adapters/pf2e-bestiary-adapter.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import type {
|
||||||
|
CreatureId,
|
||||||
|
Pf2eCreature,
|
||||||
|
TraitBlock,
|
||||||
|
TraitSegment,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { stripTags } from "./strip-tags.js";
|
||||||
|
|
||||||
|
// -- Raw Pf2eTools types (minimal, for parsing) --
|
||||||
|
|
||||||
|
interface RawPf2eCreature {
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
level?: number;
|
||||||
|
traits?: string[];
|
||||||
|
perception?: { std?: number };
|
||||||
|
senses?: { name?: string; type?: string; range?: number }[];
|
||||||
|
languages?: { languages?: string[] };
|
||||||
|
skills?: Record<string, { std?: number }>;
|
||||||
|
abilityMods?: Record<string, number>;
|
||||||
|
items?: string[];
|
||||||
|
defenses?: RawDefenses;
|
||||||
|
speed?: Record<string, number | { number: number }>;
|
||||||
|
attacks?: RawAttack[];
|
||||||
|
abilities?: {
|
||||||
|
top?: RawAbility[];
|
||||||
|
mid?: RawAbility[];
|
||||||
|
bot?: RawAbility[];
|
||||||
|
};
|
||||||
|
_copy?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawDefenses {
|
||||||
|
ac?: Record<string, unknown>;
|
||||||
|
savingThrows?: {
|
||||||
|
fort?: { std?: number };
|
||||||
|
ref?: { std?: number };
|
||||||
|
will?: { std?: number };
|
||||||
|
};
|
||||||
|
hp?: { hp?: number }[];
|
||||||
|
immunities?: (string | { name: string })[];
|
||||||
|
resistances?: { amount?: number; name: string; note?: string }[];
|
||||||
|
weaknesses?: { amount?: number; name: string; note?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAbility {
|
||||||
|
name?: string;
|
||||||
|
entries?: RawEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAttack {
|
||||||
|
range?: string;
|
||||||
|
name: string;
|
||||||
|
attack?: number;
|
||||||
|
traits?: string[];
|
||||||
|
damage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawEntry = string | RawEntryObject;
|
||||||
|
|
||||||
|
interface RawEntryObject {
|
||||||
|
type?: string;
|
||||||
|
items?: (string | { name?: string; entry?: string })[];
|
||||||
|
entries?: RawEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Module state --
|
||||||
|
|
||||||
|
let sourceDisplayNames: Record<string, string> = {};
|
||||||
|
|
||||||
|
export function setPf2eSourceDisplayNames(names: Record<string, string>): void {
|
||||||
|
sourceDisplayNames = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Helpers --
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripAngleBrackets(s: string): string {
|
||||||
|
return s.replaceAll(/<([^>]+)>/g, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCreatureId(source: string, name: string): CreatureId {
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
|
.replaceAll(/(^-|-$)/g, "");
|
||||||
|
return creatureId(`${source.toLowerCase()}:${slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(
|
||||||
|
speed: Record<string, number | { number: number }> | undefined,
|
||||||
|
): string {
|
||||||
|
if (!speed) return "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const [mode, value] of Object.entries(speed)) {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
parts.push(
|
||||||
|
mode === "walk" ? `${value} feet` : `${capitalize(mode)} ${value} feet`,
|
||||||
|
);
|
||||||
|
} else if (typeof value === "object" && "number" in value) {
|
||||||
|
parts.push(
|
||||||
|
mode === "walk"
|
||||||
|
? `${value.number} feet`
|
||||||
|
: `${capitalize(mode)} ${value.number} feet`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSkills(
|
||||||
|
skills: Record<string, { std?: number }> | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!skills) return undefined;
|
||||||
|
const parts = Object.entries(skills)
|
||||||
|
.map(([name, val]) => `${capitalize(name)} +${val.std ?? 0}`)
|
||||||
|
.sort();
|
||||||
|
return parts.length > 0 ? parts.join(", ") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSenses(
|
||||||
|
senses:
|
||||||
|
| readonly { name?: string; type?: string; range?: number }[]
|
||||||
|
| undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!senses || senses.length === 0) return undefined;
|
||||||
|
return senses
|
||||||
|
.map((s) => {
|
||||||
|
const label = stripTags(s.name ?? s.type ?? "");
|
||||||
|
if (!label) return "";
|
||||||
|
const parts = [capitalize(label)];
|
||||||
|
if (s.type && s.name) parts.push(`(${s.type})`);
|
||||||
|
if (s.range != null) parts.push(`${s.range} feet`);
|
||||||
|
return parts.join(" ");
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLanguages(
|
||||||
|
languages: { languages?: string[] } | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!languages?.languages || languages.languages.length === 0)
|
||||||
|
return undefined;
|
||||||
|
return languages.languages.map(capitalize).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImmunities(
|
||||||
|
immunities: readonly (string | { name: string })[] | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!immunities || immunities.length === 0) return undefined;
|
||||||
|
return immunities
|
||||||
|
.map((i) => capitalize(typeof i === "string" ? i : i.name))
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResistances(
|
||||||
|
resistances:
|
||||||
|
| readonly { amount?: number; name: string; note?: string }[]
|
||||||
|
| undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!resistances || resistances.length === 0) return undefined;
|
||||||
|
return resistances
|
||||||
|
.map((r) => {
|
||||||
|
const base =
|
||||||
|
r.amount == null
|
||||||
|
? capitalize(r.name)
|
||||||
|
: `${capitalize(r.name)} ${r.amount}`;
|
||||||
|
return r.note ? `${base} (${r.note})` : base;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWeaknesses(
|
||||||
|
weaknesses:
|
||||||
|
| readonly { amount?: number; name: string; note?: string }[]
|
||||||
|
| undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!weaknesses || weaknesses.length === 0) return undefined;
|
||||||
|
return weaknesses
|
||||||
|
.map((w) => {
|
||||||
|
const base =
|
||||||
|
w.amount == null
|
||||||
|
? capitalize(w.name)
|
||||||
|
: `${capitalize(w.name)} ${w.amount}`;
|
||||||
|
return w.note ? `${base} (${w.note})` : base;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Entry parsing --
|
||||||
|
|
||||||
|
function segmentizeEntries(entries: unknown): TraitSegment[] {
|
||||||
|
if (!Array.isArray(entries)) return [];
|
||||||
|
const segments: TraitSegment[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
segments.push({ type: "text", value: stripTags(entry) });
|
||||||
|
} else if (typeof entry === "object" && entry !== null) {
|
||||||
|
const obj = entry as RawEntryObject;
|
||||||
|
if (obj.type === "list" && Array.isArray(obj.items)) {
|
||||||
|
segments.push({
|
||||||
|
type: "list",
|
||||||
|
items: obj.items.map((item) => {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
return { text: stripTags(item) };
|
||||||
|
}
|
||||||
|
return { label: item.name, text: stripTags(item.entry ?? "") };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else if (Array.isArray(obj.entries)) {
|
||||||
|
segments.push(...segmentizeEntries(obj.entries));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAffliction(a: Record<string, unknown>): TraitSegment[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (a.note) parts.push(stripTags(String(a.note)));
|
||||||
|
if (a.DC) parts.push(`DC ${a.DC}`);
|
||||||
|
if (a.savingThrow) parts.push(String(a.savingThrow));
|
||||||
|
const stages = a.stages as
|
||||||
|
| { stage: number; entry: string; duration: string }[]
|
||||||
|
| undefined;
|
||||||
|
if (stages) {
|
||||||
|
for (const s of stages) {
|
||||||
|
parts.push(`Stage ${s.stage}: ${stripTags(s.entry)} (${s.duration})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? [{ type: "text", value: parts.join("; ") }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAbilities(
|
||||||
|
abilities: readonly RawAbility[] | undefined,
|
||||||
|
): TraitBlock[] | undefined {
|
||||||
|
if (!abilities || abilities.length === 0) return undefined;
|
||||||
|
return abilities
|
||||||
|
.filter((a) => a.name)
|
||||||
|
.map((a) => {
|
||||||
|
const raw = a as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
name: stripTags(a.name as string),
|
||||||
|
segments: Array.isArray(a.entries)
|
||||||
|
? segmentizeEntries(a.entries)
|
||||||
|
: formatAffliction(raw),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttacks(
|
||||||
|
attacks: readonly RawAttack[] | undefined,
|
||||||
|
): TraitBlock[] | undefined {
|
||||||
|
if (!attacks || attacks.length === 0) return undefined;
|
||||||
|
return attacks.map((a) => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (a.range) parts.push(a.range);
|
||||||
|
const attackMod = a.attack == null ? "" : ` +${a.attack}`;
|
||||||
|
const traits =
|
||||||
|
a.traits && a.traits.length > 0
|
||||||
|
? ` (${a.traits.map((t) => stripAngleBrackets(stripTags(t))).join(", ")})`
|
||||||
|
: "";
|
||||||
|
const damage = a.damage
|
||||||
|
? `, ${stripAngleBrackets(stripTags(a.damage))}`
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
name: capitalize(stripTags(a.name)),
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
value: `${parts.join(" ")}${attackMod}${traits}${damage}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Defenses extraction --
|
||||||
|
|
||||||
|
function extractDefenses(defenses: RawDefenses | undefined) {
|
||||||
|
const acRecord = defenses?.ac ?? {};
|
||||||
|
const acStd = (acRecord.std as number | undefined) ?? 0;
|
||||||
|
const acEntries = Object.entries(acRecord).filter(([k]) => k !== "std");
|
||||||
|
return {
|
||||||
|
ac: acStd,
|
||||||
|
acConditional:
|
||||||
|
acEntries.length > 0
|
||||||
|
? acEntries.map(([k, v]) => `${v} ${k}`).join(", ")
|
||||||
|
: undefined,
|
||||||
|
saveFort: defenses?.savingThrows?.fort?.std ?? 0,
|
||||||
|
saveRef: defenses?.savingThrows?.ref?.std ?? 0,
|
||||||
|
saveWill: defenses?.savingThrows?.will?.std ?? 0,
|
||||||
|
hp: defenses?.hp?.[0]?.hp ?? 0,
|
||||||
|
immunities: formatImmunities(defenses?.immunities),
|
||||||
|
resistances: formatResistances(defenses?.resistances),
|
||||||
|
weaknesses: formatWeaknesses(defenses?.weaknesses),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Main normalization --
|
||||||
|
|
||||||
|
function normalizeCreature(raw: RawPf2eCreature): Pf2eCreature {
|
||||||
|
const source = raw.source ?? "";
|
||||||
|
const defenses = extractDefenses(raw.defenses);
|
||||||
|
const mods = raw.abilityMods ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
system: "pf2e",
|
||||||
|
id: makeCreatureId(source, raw.name),
|
||||||
|
name: raw.name,
|
||||||
|
source,
|
||||||
|
sourceDisplayName: sourceDisplayNames[source] ?? source,
|
||||||
|
level: raw.level ?? 0,
|
||||||
|
traits: raw.traits ?? [],
|
||||||
|
perception: raw.perception?.std ?? 0,
|
||||||
|
senses: formatSenses(raw.senses),
|
||||||
|
languages: formatLanguages(raw.languages),
|
||||||
|
skills: formatSkills(raw.skills),
|
||||||
|
abilityMods: {
|
||||||
|
str: mods.str ?? 0,
|
||||||
|
dex: mods.dex ?? 0,
|
||||||
|
con: mods.con ?? 0,
|
||||||
|
int: mods.int ?? 0,
|
||||||
|
wis: mods.wis ?? 0,
|
||||||
|
cha: mods.cha ?? 0,
|
||||||
|
},
|
||||||
|
...defenses,
|
||||||
|
speed: formatSpeed(raw.speed),
|
||||||
|
attacks: normalizeAttacks(raw.attacks),
|
||||||
|
abilitiesTop: normalizeAbilities(raw.abilities?.top),
|
||||||
|
abilitiesMid: normalizeAbilities(raw.abilities?.mid),
|
||||||
|
abilitiesBot: normalizeAbilities(raw.abilities?.bot),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePf2eBestiary(raw: {
|
||||||
|
creature: unknown[];
|
||||||
|
}): Pf2eCreature[] {
|
||||||
|
return (raw.creature ?? [])
|
||||||
|
.filter((c: unknown) => {
|
||||||
|
const obj = c as { _copy?: unknown };
|
||||||
|
return !obj._copy;
|
||||||
|
})
|
||||||
|
.map((c) => normalizeCreature(c as RawPf2eCreature));
|
||||||
|
}
|
||||||
70
apps/web/src/adapters/pf2e-bestiary-index-adapter.ts
Normal file
70
apps/web/src/adapters/pf2e-bestiary-index-adapter.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const filename = `creatures-${sourceCode.toLowerCase()}.json`;
|
||||||
|
if (baseUrl !== undefined) {
|
||||||
|
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||||
|
return `${normalized}${filename}`;
|
||||||
|
}
|
||||||
|
return `https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPf2eSourceDisplayName(sourceCode: string): string {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
return index.sources[sourceCode] ?? sourceCode;
|
||||||
|
}
|
||||||
59
apps/web/src/adapters/ports.ts
Normal file
59
apps/web/src/adapters/ports.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type {
|
||||||
|
AnyCreature,
|
||||||
|
BestiaryIndex,
|
||||||
|
CreatureId,
|
||||||
|
Encounter,
|
||||||
|
Pf2eBestiaryIndex,
|
||||||
|
PlayerCharacter,
|
||||||
|
UndoRedoState,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
|
||||||
|
export interface EncounterPersistence {
|
||||||
|
load(): Encounter | null;
|
||||||
|
save(encounter: Encounter): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UndoRedoPersistence {
|
||||||
|
load(): UndoRedoState;
|
||||||
|
save(state: UndoRedoState): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerCharacterPersistence {
|
||||||
|
load(): PlayerCharacter[];
|
||||||
|
save(characters: PlayerCharacter[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CachedSourceInfo {
|
||||||
|
readonly sourceCode: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly creatureCount: number;
|
||||||
|
readonly cachedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BestiaryCachePort {
|
||||||
|
cacheSource(
|
||||||
|
system: string,
|
||||||
|
sourceCode: string,
|
||||||
|
displayName: string,
|
||||||
|
creatures: AnyCreature[],
|
||||||
|
): Promise<void>;
|
||||||
|
isSourceCached(system: string, sourceCode: string): Promise<boolean>;
|
||||||
|
getCachedSources(system?: string): Promise<CachedSourceInfo[]>;
|
||||||
|
clearSource(system: string, sourceCode: string): Promise<void>;
|
||||||
|
clearAll(): Promise<void>;
|
||||||
|
loadAllCachedCreatures(): Promise<Map<CreatureId, AnyCreature>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BestiaryIndexPort {
|
||||||
|
loadIndex(): BestiaryIndex;
|
||||||
|
getAllSourceCodes(): string[];
|
||||||
|
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||||
|
getSourceDisplayName(sourceCode: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pf2eBestiaryIndexPort {
|
||||||
|
loadIndex(): Pf2eBestiaryIndex;
|
||||||
|
getAllSourceCodes(): string[];
|
||||||
|
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||||
|
getSourceDisplayName(sourceCode: string): string;
|
||||||
|
}
|
||||||
51
apps/web/src/adapters/production-adapters.ts
Normal file
51
apps/web/src/adapters/production-adapters.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Adapters } from "../contexts/adapter-context.js";
|
||||||
|
import {
|
||||||
|
loadEncounter,
|
||||||
|
saveEncounter,
|
||||||
|
} from "../persistence/encounter-storage.js";
|
||||||
|
import {
|
||||||
|
loadPlayerCharacters,
|
||||||
|
savePlayerCharacters,
|
||||||
|
} from "../persistence/player-character-storage.js";
|
||||||
|
import {
|
||||||
|
loadUndoRedoStacks,
|
||||||
|
saveUndoRedoStacks,
|
||||||
|
} from "../persistence/undo-redo-storage.js";
|
||||||
|
import * as bestiaryCache from "./bestiary-cache.js";
|
||||||
|
import * as bestiaryIndex from "./bestiary-index-adapter.js";
|
||||||
|
import * as pf2eBestiaryIndex from "./pf2e-bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
export const productionAdapters: Adapters = {
|
||||||
|
encounterPersistence: {
|
||||||
|
load: loadEncounter,
|
||||||
|
save: saveEncounter,
|
||||||
|
},
|
||||||
|
undoRedoPersistence: {
|
||||||
|
load: loadUndoRedoStacks,
|
||||||
|
save: saveUndoRedoStacks,
|
||||||
|
},
|
||||||
|
playerCharacterPersistence: {
|
||||||
|
load: loadPlayerCharacters,
|
||||||
|
save: savePlayerCharacters,
|
||||||
|
},
|
||||||
|
bestiaryCache: {
|
||||||
|
cacheSource: bestiaryCache.cacheSource,
|
||||||
|
isSourceCached: bestiaryCache.isSourceCached,
|
||||||
|
getCachedSources: bestiaryCache.getCachedSources,
|
||||||
|
clearSource: bestiaryCache.clearSource,
|
||||||
|
clearAll: bestiaryCache.clearAll,
|
||||||
|
loadAllCachedCreatures: bestiaryCache.loadAllCachedCreatures,
|
||||||
|
},
|
||||||
|
bestiaryIndex: {
|
||||||
|
loadIndex: bestiaryIndex.loadBestiaryIndex,
|
||||||
|
getAllSourceCodes: bestiaryIndex.getAllSourceCodes,
|
||||||
|
getDefaultFetchUrl: bestiaryIndex.getDefaultFetchUrl,
|
||||||
|
getSourceDisplayName: bestiaryIndex.getSourceDisplayName,
|
||||||
|
},
|
||||||
|
pf2eBestiaryIndex: {
|
||||||
|
loadIndex: pf2eBestiaryIndex.loadPf2eBestiaryIndex,
|
||||||
|
getAllSourceCodes: pf2eBestiaryIndex.getAllPf2eSourceCodes,
|
||||||
|
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
|
||||||
|
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,41 +1,16 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { playerCharacterId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { ActionBar } from "../action-bar.js";
|
import { ActionBar } from "../action-bar.js";
|
||||||
|
|
||||||
// Mock persistence — no localStorage interaction
|
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: () => null,
|
|
||||||
saveEncounter: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
|
||||||
loadPlayerCharacters: () => [],
|
|
||||||
savePlayerCharacters: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock bestiary — no IndexedDB or JSON index
|
|
||||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
|
||||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
|
||||||
isSourceCached: () => Promise.resolve(false),
|
|
||||||
cacheSource: () => Promise.resolve(),
|
|
||||||
getCachedSources: () => Promise.resolve([]),
|
|
||||||
clearSource: () => Promise.resolve(),
|
|
||||||
clearAll: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// DOM API stubs — jsdom doesn't implement these
|
// DOM API stubs — jsdom doesn't implement these
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(globalThis, "matchMedia", {
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
@@ -60,10 +35,97 @@ function renderBar(props: Partial<Parameters<typeof ActionBar>[0]> = {}) {
|
|||||||
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
return render(<ActionBar {...props} />, { wrapper: AllProviders });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderBarWithBestiary(
|
||||||
|
props: Partial<Parameters<typeof ActionBar>[0]> = {},
|
||||||
|
) {
|
||||||
|
const adapters = createTestAdapters();
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
|
loadIndex: () => ({
|
||||||
|
sources: { MM: "Monster Manual" },
|
||||||
|
creatures: [
|
||||||
|
{
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Golem, Iron",
|
||||||
|
source: "MM",
|
||||||
|
ac: 20,
|
||||||
|
hp: 210,
|
||||||
|
dex: 9,
|
||||||
|
cr: "16",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Large",
|
||||||
|
type: "construct",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
getSourceDisplayName: (code: string) =>
|
||||||
|
code === "MM" ? "Monster Manual" : code,
|
||||||
|
};
|
||||||
|
return render(<ActionBar {...props} />, {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBarWithPCs(
|
||||||
|
props: Partial<Parameters<typeof ActionBar>[0]> = {},
|
||||||
|
) {
|
||||||
|
const adapters = createTestAdapters({
|
||||||
|
playerCharacters: [
|
||||||
|
{
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Gandalf",
|
||||||
|
ac: 15,
|
||||||
|
maxHp: 40,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
|
loadIndex: () => ({
|
||||||
|
sources: { MM: "Monster Manual" },
|
||||||
|
creatures: [
|
||||||
|
{
|
||||||
|
name: "Goblin",
|
||||||
|
source: "MM",
|
||||||
|
ac: 15,
|
||||||
|
hp: 7,
|
||||||
|
dex: 14,
|
||||||
|
cr: "1/4",
|
||||||
|
initiativeProficiency: 0,
|
||||||
|
size: "Small",
|
||||||
|
type: "humanoid",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
getSourceDisplayName: (code: string) =>
|
||||||
|
code === "MM" ? "Monster Manual" : code,
|
||||||
|
};
|
||||||
|
return render(<ActionBar {...props} />, {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("ActionBar", () => {
|
describe("ActionBar", () => {
|
||||||
|
describe("basic rendering and custom add", () => {
|
||||||
it("renders input with placeholder '+ Add combatants'", () => {
|
it("renders input with placeholder '+ Add combatants'", () => {
|
||||||
renderBar();
|
renderBar();
|
||||||
expect(screen.getByPlaceholderText("+ Add combatants")).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByPlaceholderText("+ Add combatants"),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submitting with a name adds a combatant", async () => {
|
it("submitting with a name adds a combatant", async () => {
|
||||||
@@ -71,20 +133,16 @@ describe("ActionBar", () => {
|
|||||||
renderBar();
|
renderBar();
|
||||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
await user.type(input, "Goblin");
|
await user.type(input, "Goblin");
|
||||||
// The Add button appears when name >= 2 chars and no suggestions
|
|
||||||
const addButton = screen.getByRole("button", { name: "Add" });
|
const addButton = screen.getByRole("button", { name: "Add" });
|
||||||
await user.click(addButton);
|
await user.click(addButton);
|
||||||
// Input is cleared after adding (context handles the state)
|
|
||||||
expect(input).toHaveValue("");
|
expect(input).toHaveValue("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submitting with empty name does nothing", async () => {
|
it("submitting with empty name does nothing", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderBar();
|
renderBar();
|
||||||
// Submit the form directly (Enter on empty input)
|
|
||||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
await user.type(input, "{Enter}");
|
await user.type(input, "{Enter}");
|
||||||
// Input stays empty, no error
|
|
||||||
expect(input).toHaveValue("");
|
expect(input).toHaveValue("");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,6 +164,160 @@ describe("ActionBar", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("submits custom stats with combatant", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBar();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Fighter");
|
||||||
|
await user.type(screen.getByPlaceholderText("Init"), "15");
|
||||||
|
await user.type(screen.getByPlaceholderText("AC"), "18");
|
||||||
|
await user.type(screen.getByPlaceholderText("MaxHP"), "45");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bestiary suggestions and queuing", () => {
|
||||||
|
it("shows bestiary suggestions when typing a matching name", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Golem, Iron")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a suggestion queues it with count badge", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the Goblin suggestion
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
|
||||||
|
// Should show count badge "1"
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking same suggestion again increments count", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
|
||||||
|
expect(screen.getByText("2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirming queued creatures adds them to the encounter", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue 1 Goblin
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
|
||||||
|
// Press Enter to confirm the queued creature
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
|
||||||
|
// Input should be cleared after confirming
|
||||||
|
expect(input).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears queued when search text no longer matches", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByText("Goblin"));
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Change search to something with no matches
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "xyz");
|
||||||
|
|
||||||
|
// Count badge should be gone
|
||||||
|
expect(screen.queryByText("1")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("player character matching", () => {
|
||||||
|
it("shows matching player characters in suggestions", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithPCs();
|
||||||
|
const input = screen.getByPlaceholderText("+ Add combatants");
|
||||||
|
await user.type(input, "Gan");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Gandalf")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Player")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("browse mode", () => {
|
||||||
|
it("toggles browse mode via eye icon button", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
|
||||||
|
const browseButton = screen.getByRole("button", {
|
||||||
|
name: "Browse stat blocks",
|
||||||
|
});
|
||||||
|
await user.click(browseButton);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText("Search stat blocks..."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Switch to add mode" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("browse mode shows suggestions without add UI", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderBarWithBestiary();
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Browse stat blocks" }),
|
||||||
|
);
|
||||||
|
const input = screen.getByPlaceholderText("Search stat blocks...");
|
||||||
|
await user.type(input, "Go");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
// No Add button in browse mode
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Add" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("overflow menu", () => {
|
||||||
it("does not show roll all initiative button when no creature combatants", () => {
|
it("does not show roll all initiative button when no creature combatants", () => {
|
||||||
renderBar();
|
renderBar();
|
||||||
expect(
|
expect(
|
||||||
@@ -115,7 +327,6 @@ describe("ActionBar", () => {
|
|||||||
|
|
||||||
it("shows overflow menu items", () => {
|
it("shows overflow menu items", () => {
|
||||||
renderBar({ onManagePlayers: vi.fn() });
|
renderBar({ onManagePlayers: vi.fn() });
|
||||||
// The overflow menu should be present (it contains Player Characters etc.)
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "More actions" }),
|
screen.getByRole("button", { name: "More actions" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -125,10 +336,8 @@ describe("ActionBar", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderBar();
|
renderBar();
|
||||||
await user.click(screen.getByRole("button", { name: "More actions" }));
|
await user.click(screen.getByRole("button", { name: "More actions" }));
|
||||||
// Click the menu item
|
|
||||||
const items = screen.getAllByText("Export Encounter");
|
const items = screen.getAllByText("Export Encounter");
|
||||||
await user.click(items[0]);
|
await user.click(items[0]);
|
||||||
// Dialog should now be open — it renders a second "Export Encounter" as heading
|
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByText("Export Encounter").length,
|
screen.getAllByText("Export Encounter").length,
|
||||||
).toBeGreaterThanOrEqual(1);
|
).toBeGreaterThanOrEqual(1);
|
||||||
@@ -162,19 +371,5 @@ describe("ActionBar", () => {
|
|||||||
await user.click(screen.getByText("Settings"));
|
await user.click(screen.getByText("Settings"));
|
||||||
expect(onOpenSettings).toHaveBeenCalledOnce();
|
expect(onOpenSettings).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submits custom stats with combatant", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderBar();
|
|
||||||
const input = screen.getByPlaceholderText("+ Add combatants");
|
|
||||||
await user.type(input, "Fighter");
|
|
||||||
const initInput = screen.getByPlaceholderText("Init");
|
|
||||||
const acInput = screen.getByPlaceholderText("AC");
|
|
||||||
const hpInput = screen.getByPlaceholderText("MaxHP");
|
|
||||||
await user.type(initInput, "15");
|
|
||||||
await user.type(acInput, "18");
|
|
||||||
await user.type(hpInput, "45");
|
|
||||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
|
||||||
expect(input).toHaveValue("");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { 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 { 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/;
|
||||||
@@ -28,6 +31,10 @@ let mockImportState = {
|
|||||||
failed: 0,
|
failed: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Uses context mocks because the bulk import state machine (idle → loading →
|
||||||
|
// complete → partial-failure) is impractical to drive through user interactions
|
||||||
|
// without real network calls. Consider migrating if adapter injection expands
|
||||||
|
// to cover these state transitions.
|
||||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||||
useBestiaryContext: () => ({
|
useBestiaryContext: () => ({
|
||||||
fetchAndCacheSource: mockFetchAndCacheSource,
|
fetchAndCacheSource: mockFetchAndCacheSource,
|
||||||
@@ -50,12 +57,25 @@ vi.mock("../../contexts/side-panel-context.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
function createAdaptersWithSources() {
|
||||||
|
const adapters = createTestAdapters();
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
||||||
getDefaultFetchUrl: () => "",
|
};
|
||||||
getSourceDisplayName: (code: string) => code,
|
return adapters;
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
}
|
||||||
}));
|
|
||||||
|
function renderWithAdapters() {
|
||||||
|
const adapters = createAdaptersWithSources();
|
||||||
|
return render(
|
||||||
|
<RulesEditionProvider>
|
||||||
|
<AdapterProvider adapters={adapters}>
|
||||||
|
<BulkImportPrompt />
|
||||||
|
</AdapterProvider>
|
||||||
|
</RulesEditionProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("BulkImportPrompt", () => {
|
describe("BulkImportPrompt", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -64,7 +84,7 @@ describe("BulkImportPrompt", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("idle: shows base URL input, source count, Load All button", () => {
|
it("idle: shows base URL input, source count, Load All button", () => {
|
||||||
render(<BulkImportPrompt />);
|
renderWithAdapters();
|
||||||
expect(screen.getByText(THREE_SOURCES_REGEX)).toBeInTheDocument();
|
expect(screen.getByText(THREE_SOURCES_REGEX)).toBeInTheDocument();
|
||||||
expect(screen.getByDisplayValue(GITHUB_URL_REGEX)).toBeInTheDocument();
|
expect(screen.getByDisplayValue(GITHUB_URL_REGEX)).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
@@ -74,7 +94,7 @@ describe("BulkImportPrompt", () => {
|
|||||||
|
|
||||||
it("idle: clearing URL disables the button", async () => {
|
it("idle: clearing URL disables the button", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<BulkImportPrompt />);
|
renderWithAdapters();
|
||||||
|
|
||||||
const input = screen.getByDisplayValue(GITHUB_URL_REGEX);
|
const input = screen.getByDisplayValue(GITHUB_URL_REGEX);
|
||||||
await user.clear(input);
|
await user.clear(input);
|
||||||
@@ -83,7 +103,7 @@ describe("BulkImportPrompt", () => {
|
|||||||
|
|
||||||
it("idle: clicking Load All calls startImport with URL", async () => {
|
it("idle: clicking Load All calls startImport with URL", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<BulkImportPrompt />);
|
renderWithAdapters();
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Load All" }));
|
await user.click(screen.getByRole("button", { name: "Load All" }));
|
||||||
expect(mockStartImport).toHaveBeenCalledWith(
|
expect(mockStartImport).toHaveBeenCalledWith(
|
||||||
@@ -101,7 +121,7 @@ describe("BulkImportPrompt", () => {
|
|||||||
completed: 3,
|
completed: 3,
|
||||||
failed: 1,
|
failed: 1,
|
||||||
};
|
};
|
||||||
render(<BulkImportPrompt />);
|
renderWithAdapters();
|
||||||
expect(screen.getByText(LOADING_PROGRESS_REGEX)).toBeInTheDocument();
|
expect(screen.getByText(LOADING_PROGRESS_REGEX)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,7 +132,7 @@ describe("BulkImportPrompt", () => {
|
|||||||
completed: 10,
|
completed: 10,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
};
|
};
|
||||||
render(<BulkImportPrompt />);
|
renderWithAdapters();
|
||||||
expect(screen.getByText("All sources loaded")).toBeInTheDocument();
|
expect(screen.getByText("All sources loaded")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -125,7 +145,7 @@ describe("BulkImportPrompt", () => {
|
|||||||
failed: 0,
|
failed: 0,
|
||||||
};
|
};
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<BulkImportPrompt />);
|
renderWithAdapters();
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Done" }));
|
await user.click(screen.getByRole("button", { name: "Done" }));
|
||||||
expect(mockDismissPanel).toHaveBeenCalled();
|
expect(mockDismissPanel).toHaveBeenCalled();
|
||||||
@@ -139,7 +159,7 @@ describe("BulkImportPrompt", () => {
|
|||||||
completed: 7,
|
completed: 7,
|
||||||
failed: 3,
|
failed: 3,
|
||||||
};
|
};
|
||||||
render(<BulkImportPrompt />);
|
renderWithAdapters();
|
||||||
expect(screen.getByText(SEVEN_OF_TEN_REGEX)).toBeInTheDocument();
|
expect(screen.getByText(SEVEN_OF_TEN_REGEX)).toBeInTheDocument();
|
||||||
expect(screen.getByText(THREE_FAILED_REGEX)).toBeInTheDocument();
|
expect(screen.getByText(THREE_FAILED_REGEX)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,34 +13,6 @@ const TEMP_HP_REGEX = /^\+\d/;
|
|||||||
const CURRENT_HP_7_REGEX = /Current HP: 7/;
|
const CURRENT_HP_7_REGEX = /Current HP: 7/;
|
||||||
const CURRENT_HP_REGEX = /Current HP/;
|
const CURRENT_HP_REGEX = /Current HP/;
|
||||||
|
|
||||||
// Mock persistence — no localStorage interaction
|
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: () => null,
|
|
||||||
saveEncounter: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
|
||||||
loadPlayerCharacters: () => [],
|
|
||||||
savePlayerCharacters: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock bestiary — no IndexedDB or JSON index
|
|
||||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
|
||||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
|
||||||
isSourceCached: () => Promise.resolve(false),
|
|
||||||
cacheSource: () => Promise.resolve(),
|
|
||||||
getCachedSources: () => Promise.resolve([]),
|
|
||||||
clearSource: () => Promise.resolve(),
|
|
||||||
clearAll: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// DOM API stubs
|
// DOM API stubs
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
Object.defineProperty(globalThis, "matchMedia", {
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// @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,
|
||||||
|
} 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 RefObject } from "react";
|
||||||
@@ -13,12 +17,14 @@ afterEach(cleanup);
|
|||||||
|
|
||||||
function renderPicker(
|
function renderPicker(
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
activeConditions: readonly ConditionId[];
|
activeConditions: readonly ConditionEntry[];
|
||||||
onToggle: (conditionId: ConditionId) => void;
|
onToggle: (conditionId: ConditionId) => void;
|
||||||
|
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}> = {},
|
}> = {},
|
||||||
) {
|
) {
|
||||||
const onToggle = overrides.onToggle ?? vi.fn();
|
const onToggle = overrides.onToggle ?? vi.fn();
|
||||||
|
const onSetValue = overrides.onSetValue ?? vi.fn();
|
||||||
const onClose = overrides.onClose ?? vi.fn();
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
||||||
const anchor = document.createElement("div");
|
const anchor = document.createElement("div");
|
||||||
@@ -30,25 +36,27 @@ function renderPicker(
|
|||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
activeConditions={overrides.activeConditions ?? []}
|
activeConditions={overrides.activeConditions ?? []}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
|
onSetValue={onSetValue}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
</RulesEditionProvider>,
|
</RulesEditionProvider>,
|
||||||
);
|
);
|
||||||
return { ...result, onToggle, onClose };
|
return { ...result, onToggle, onSetValue, 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,7 +73,7 @@ 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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,38 +1,36 @@
|
|||||||
// @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";
|
||||||
|
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||||
import { ConditionTags } from "../condition-tags.js";
|
import { ConditionTags } from "../condition-tags.js";
|
||||||
|
|
||||||
vi.mock("../../contexts/rules-edition-context.js", () => ({
|
|
||||||
useRulesEditionContext: () => ({ edition: "5.5e" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderTags(props: Partial<Parameters<typeof ConditionTags>[0]> = {}) {
|
||||||
|
return render(
|
||||||
|
<RulesEditionProvider>
|
||||||
|
<ConditionTags
|
||||||
|
conditions={props.conditions}
|
||||||
|
onRemove={props.onRemove ?? (() => {})}
|
||||||
|
onDecrement={props.onDecrement ?? (() => {})}
|
||||||
|
onOpenPicker={props.onOpenPicker ?? (() => {})}
|
||||||
|
/>
|
||||||
|
</RulesEditionProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("ConditionTags", () => {
|
describe("ConditionTags", () => {
|
||||||
it("renders nothing when conditions is undefined", () => {
|
it("renders nothing when conditions is undefined", () => {
|
||||||
const { container } = render(
|
const { container } = renderTags();
|
||||||
<ConditionTags
|
|
||||||
conditions={undefined}
|
|
||||||
onRemove={() => {}}
|
|
||||||
onOpenPicker={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
// Only the add button should be present
|
// Only the add button should be present
|
||||||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a button per condition", () => {
|
it("renders a button per condition", () => {
|
||||||
const conditions: ConditionId[] = ["blinded", "prone"];
|
const conditions: ConditionEntry[] = [{ id: "blinded" }, { id: "prone" }];
|
||||||
render(
|
renderTags({ conditions });
|
||||||
<ConditionTags
|
|
||||||
conditions={conditions}
|
|
||||||
onRemove={() => {}}
|
|
||||||
onOpenPicker={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
@@ -41,13 +39,10 @@ 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();
|
||||||
render(
|
renderTags({
|
||||||
<ConditionTags
|
conditions: [{ id: "blinded" }] as ConditionEntry[],
|
||||||
conditions={["blinded"] as ConditionId[]}
|
onRemove,
|
||||||
onRemove={onRemove}
|
});
|
||||||
onOpenPicker={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await userEvent.click(
|
await userEvent.click(
|
||||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||||
@@ -58,13 +53,7 @@ describe("ConditionTags", () => {
|
|||||||
|
|
||||||
it("calls onOpenPicker when add button is clicked", async () => {
|
it("calls onOpenPicker when add button is clicked", async () => {
|
||||||
const onOpenPicker = vi.fn();
|
const onOpenPicker = vi.fn();
|
||||||
render(
|
renderTags({ conditions: [], onOpenPicker });
|
||||||
<ConditionTags
|
|
||||||
conditions={[]}
|
|
||||||
onRemove={() => {}}
|
|
||||||
onOpenPicker={onOpenPicker}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await userEvent.click(
|
await userEvent.click(
|
||||||
screen.getByRole("button", { name: "Add condition" }),
|
screen.getByRole("button", { name: "Add condition" }),
|
||||||
@@ -74,14 +63,41 @@ describe("ConditionTags", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders empty conditions array without errors", () => {
|
it("renders empty conditions array without errors", () => {
|
||||||
render(
|
renderTags({ conditions: [] });
|
||||||
<ConditionTags
|
|
||||||
conditions={[]}
|
|
||||||
onRemove={() => {}}
|
|
||||||
onOpenPicker={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
// 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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
render,
|
||||||
|
renderHook,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, 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 { useRulesEdition } from "../../hooks/use-rules-edition.js";
|
||||||
|
import { DifficultyBreakdownPanel } from "../difficulty-breakdown-panel.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(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const pcId1 = playerCharacterId("pc-1");
|
||||||
|
const goblinCreature = buildCreature({
|
||||||
|
id: creatureId("srd:goblin"),
|
||||||
|
name: "Goblin",
|
||||||
|
cr: "1/4",
|
||||||
|
source: "srd",
|
||||||
|
sourceDisplayName: "SRD",
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPanel(options: {
|
||||||
|
encounter: ReturnType<typeof buildEncounter>;
|
||||||
|
playerCharacters?: PlayerCharacter[];
|
||||||
|
creatures?: Map<CreatureId, Creature>;
|
||||||
|
onClose?: () => void;
|
||||||
|
}) {
|
||||||
|
const adapters = createTestAdapters({
|
||||||
|
encounter: options.encounter,
|
||||||
|
playerCharacters: options.playerCharacters ?? [],
|
||||||
|
creatures: options.creatures,
|
||||||
|
});
|
||||||
|
return render(
|
||||||
|
<AllProviders adapters={adapters}>
|
||||||
|
<DifficultyBreakdownPanel onClose={options.onClose ?? (() => {})} />
|
||||||
|
</AllProviders>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultEncounter() {
|
||||||
|
return 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: "Custom Thug",
|
||||||
|
cr: "2",
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-4"),
|
||||||
|
name: "Bandit",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPCs: PlayerCharacter[] = [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("DifficultyBreakdownPanel", () => {
|
||||||
|
it("renders party budget section", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Party Budget", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1 PC", { exact: false })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Low:", { exact: false })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders tier label", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Encounter Difficulty:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows PC in party column with level", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||||
|
expect(pickers).toHaveLength(2);
|
||||||
|
expect(pickers[0]).toHaveValue("2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selecting a CR updates the visible XP value", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByLabelText("Challenge rating")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickers = screen.getAllByLabelText("Challenge rating");
|
||||||
|
await user.selectOptions(pickers[1], "5");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("1,800")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-PC combatants show toggle button", async () => {
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// 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", () => {
|
||||||
|
const { container } = renderPanel({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Custom" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderPanel({
|
||||||
|
encounter: defaultEncounter(),
|
||||||
|
playerCharacters: defaultPCs,
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import type { DifficultyResult } from "@initiative/domain";
|
import type { DifficultyResult } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { DifficultyIndicator } from "../difficulty-indicator.js";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
DifficultyIndicator,
|
||||||
|
TIER_LABELS_5_5E,
|
||||||
|
TIER_LABELS_2014,
|
||||||
|
} from "../difficulty-indicator.js";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -10,50 +15,114 @@ 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();
|
).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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClick when clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(2)}
|
||||||
|
labels={TIER_LABELS_5_5E}
|
||||||
|
onClick={handleClick}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("img", { name: "Moderate encounter difficulty" }),
|
||||||
|
);
|
||||||
|
expect(handleClick).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders as div when onClick not provided", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator result={makeResult(2)} labels={TIER_LABELS_5_5E} />,
|
||||||
|
);
|
||||||
|
const element = container.querySelector("[role='img']");
|
||||||
|
expect(element?.tagName).toBe("DIV");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders as button when onClick provided", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(2)}
|
||||||
|
labels={TIER_LABELS_5_5E}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const element = container.querySelector("[role='img']");
|
||||||
|
expect(element?.tagName).toBe("BUTTON");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,32 +33,6 @@ beforeAll(() => {
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: () => null,
|
|
||||||
saveEncounter: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
|
||||||
loadPlayerCharacters: () => [],
|
|
||||||
savePlayerCharacters: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
|
||||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
|
||||||
isSourceCached: () => Promise.resolve(false),
|
|
||||||
cacheSource: () => Promise.resolve(),
|
|
||||||
getCachedSources: () => Promise.resolve([]),
|
|
||||||
clearSource: () => Promise.resolve(),
|
|
||||||
clearAll: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function renderSection() {
|
function renderSection() {
|
||||||
const ref = createRef<PlayerCharacterSectionHandle>();
|
const ref = createRef<PlayerCharacterSectionHandle>();
|
||||||
const result = render(<PlayerCharacterSection ref={ref} />, {
|
const result = render(<PlayerCharacterSection ref={ref} />, {
|
||||||
|
|||||||
@@ -28,32 +28,6 @@ beforeAll(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: () => null,
|
|
||||||
saveEncounter: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
|
||||||
loadPlayerCharacters: () => [],
|
|
||||||
savePlayerCharacters: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
|
||||||
loadAllCachedCreatures: () => Promise.resolve(new Map()),
|
|
||||||
isSourceCached: () => Promise.resolve(false),
|
|
||||||
cacheSource: () => Promise.resolve(),
|
|
||||||
getCachedSources: () => Promise.resolve([]),
|
|
||||||
clearSource: () => Promise.resolve(),
|
|
||||||
clearAll: () => Promise.resolve(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
getDefaultFetchUrl: () => "",
|
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function renderModal(open = true) {
|
function renderModal(open = true) {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const result = render(<SettingsModal open={open} onClose={onClose} />, {
|
const result = render(<SettingsModal open={open} onClose={onClose} />, {
|
||||||
@@ -63,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", () => {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, render, screen, waitFor } 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 { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||||
|
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||||
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
|
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
|
||||||
|
|
||||||
const MONSTER_MANUAL_REGEX = /Monster Manual/;
|
const MONSTER_MANUAL_REGEX = /Monster Manual/;
|
||||||
@@ -13,6 +16,9 @@ afterEach(cleanup);
|
|||||||
const mockFetchAndCacheSource = vi.fn();
|
const mockFetchAndCacheSource = vi.fn();
|
||||||
const mockUploadAndCacheSource = vi.fn();
|
const mockUploadAndCacheSource = vi.fn();
|
||||||
|
|
||||||
|
// Uses context mock because fetchAndCacheSource/uploadAndCacheSource involve
|
||||||
|
// real fetch() calls. The test controls success/failure to verify the
|
||||||
|
// component's loading and error UI, not the fetching logic itself.
|
||||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
vi.mock("../../contexts/bestiary-context.js", () => ({
|
||||||
useBestiaryContext: () => ({
|
useBestiaryContext: () => ({
|
||||||
fetchAndCacheSource: mockFetchAndCacheSource,
|
fetchAndCacheSource: mockFetchAndCacheSource,
|
||||||
@@ -20,22 +26,25 @@ vi.mock("../../contexts/bestiary-context.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.js", () => ({
|
function renderPrompt(sourceCode = "MM") {
|
||||||
|
const onSourceLoaded = vi.fn();
|
||||||
|
const adapters = createTestAdapters();
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
getDefaultFetchUrl: (code: string) =>
|
getDefaultFetchUrl: (code: string) =>
|
||||||
`https://example.com/bestiary/${code}.json`,
|
`https://example.com/bestiary/${code}.json`,
|
||||||
getSourceDisplayName: (code: string) =>
|
getSourceDisplayName: (code: string) =>
|
||||||
code === "MM" ? "Monster Manual" : code,
|
code === "MM" ? "Monster Manual" : code,
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
};
|
||||||
getAllSourceCodes: () => [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
function renderPrompt(sourceCode = "MM") {
|
|
||||||
const onSourceLoaded = vi.fn();
|
|
||||||
const result = render(
|
const result = render(
|
||||||
|
<RulesEditionProvider>
|
||||||
|
<AdapterProvider adapters={adapters}>
|
||||||
<SourceFetchPrompt
|
<SourceFetchPrompt
|
||||||
sourceCode={sourceCode}
|
sourceCode={sourceCode}
|
||||||
onSourceLoaded={onSourceLoaded}
|
onSourceLoaded={onSourceLoaded}
|
||||||
/>,
|
/>
|
||||||
|
</AdapterProvider>
|
||||||
|
</RulesEditionProvider>,
|
||||||
);
|
);
|
||||||
return { ...result, onSourceLoaded };
|
return { ...result, onSourceLoaded };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,60 +3,68 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
|
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, render, screen, waitFor } 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 type { ReactNode } from "react";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
vi.mock("../../adapters/bestiary-cache.js", () => ({
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
getCachedSources: vi.fn(),
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
clearSource: vi.fn(),
|
import type { CachedSourceInfo } from "../../adapters/ports.js";
|
||||||
clearAll: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the context module
|
|
||||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
|
||||||
useBestiaryContext: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import * as bestiaryCache from "../../adapters/bestiary-cache.js";
|
|
||||||
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
|
|
||||||
import { SourceManager } from "../source-manager.js";
|
import { SourceManager } from "../source-manager.js";
|
||||||
|
|
||||||
const mockGetCachedSources = vi.mocked(bestiaryCache.getCachedSources);
|
beforeAll(() => {
|
||||||
const mockClearSource = vi.mocked(bestiaryCache.clearSource);
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
const mockClearAll = vi.mocked(bestiaryCache.clearAll);
|
writable: true,
|
||||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
afterEach(() => {
|
media: query,
|
||||||
cleanup();
|
onchange: null,
|
||||||
vi.clearAllMocks();
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupMockContext() {
|
afterEach(cleanup);
|
||||||
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
|
||||||
mockUseBestiaryContext.mockReturnValue({
|
function renderWithSources(sources: CachedSourceInfo[] = []) {
|
||||||
refreshCache,
|
const adapters = createTestAdapters();
|
||||||
search: vi.fn().mockReturnValue([]),
|
// Wire getCachedSources to return the provided sources initially,
|
||||||
getCreature: vi.fn(),
|
// then empty after clear operations
|
||||||
isLoaded: true,
|
let currentSources = [...sources];
|
||||||
isSourceCached: vi.fn().mockResolvedValue(false),
|
adapters.bestiaryCache = {
|
||||||
fetchAndCacheSource: vi.fn(),
|
...adapters.bestiaryCache,
|
||||||
uploadAndCacheSource: vi.fn(),
|
getCachedSources: () => Promise.resolve(currentSources),
|
||||||
} as ReturnType<typeof useBestiaryContext>);
|
clearSource(_system, sourceCode) {
|
||||||
return { refreshCache };
|
currentSources = currentSources.filter(
|
||||||
|
(s) => s.sourceCode !== sourceCode,
|
||||||
|
);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
clearAll() {
|
||||||
|
currentSources = [];
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<SourceManager />, {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 () => {
|
||||||
setupMockContext();
|
void renderWithSources([]);
|
||||||
mockGetCachedSources.mockResolvedValue([]);
|
|
||||||
render(<SourceManager />);
|
|
||||||
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 () => {
|
||||||
setupMockContext();
|
void renderWithSources([
|
||||||
mockGetCachedSources.mockResolvedValue([
|
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
@@ -70,7 +78,6 @@ describe("SourceManager", () => {
|
|||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
render(<SourceManager />);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -79,38 +86,31 @@ describe("SourceManager", () => {
|
|||||||
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
expect(screen.getByText("100 creatures")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Clear All button calls cache clear and refreshCache", async () => {
|
it("Clear All button removes all sources", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { refreshCache } = setupMockContext();
|
void renderWithSources([
|
||||||
mockGetCachedSources
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
creatureCount: 300,
|
creatureCount: 300,
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
.mockResolvedValue([]);
|
|
||||||
mockClearAll.mockResolvedValue(undefined);
|
|
||||||
render(<SourceManager />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Clear All" }));
|
await user.click(screen.getByRole("button", { name: "Clear All" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockClearAll).toHaveBeenCalled();
|
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(refreshCache).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("individual source delete button calls clear for that source", async () => {
|
it("individual source delete button removes that source", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { refreshCache } = setupMockContext();
|
void renderWithSources([
|
||||||
mockGetCachedSources
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
@@ -123,18 +123,8 @@ describe("SourceManager", () => {
|
|||||||
creatureCount: 100,
|
creatureCount: 100,
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
},
|
},
|
||||||
])
|
|
||||||
.mockResolvedValue([
|
|
||||||
{
|
|
||||||
sourceCode: "vgm",
|
|
||||||
displayName: "Volo's Guide",
|
|
||||||
creatureCount: 100,
|
|
||||||
cachedAt: Date.now(),
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
mockClearSource.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
render(<SourceManager />);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
expect(screen.getByText("Monster Manual")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -142,9 +132,10 @@ describe("SourceManager", () => {
|
|||||||
await user.click(
|
await user.click(
|
||||||
screen.getByRole("button", { name: "Remove Monster Manual" }),
|
screen.getByRole("button", { name: "Remove Monster Manual" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockClearSource).toHaveBeenCalledWith("mm");
|
expect(screen.queryByText("Monster Manual")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(refreshCache).toHaveBeenCalled();
|
expect(screen.getByText("Volo's Guide")).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,8 +95,16 @@ 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: [
|
||||||
|
|||||||
@@ -1,100 +1,68 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import type { Encounter } from "@initiative/domain";
|
|
||||||
import { combatantId } from "@initiative/domain";
|
import { combatantId } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import type { ReactNode } from "react";
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
// Mock the context modules
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
import {
|
||||||
useEncounterContext: vi.fn(),
|
buildCombatant,
|
||||||
}));
|
buildEncounter,
|
||||||
|
} from "../../__tests__/factories/index.js";
|
||||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
usePlayerCharactersContext: vi.fn().mockReturnValue({ characters: [] }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
|
||||||
useBestiaryContext: vi.fn().mockReturnValue({ getCreature: () => undefined }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
|
||||||
import { TurnNavigation } from "../turn-navigation.js";
|
import { TurnNavigation } from "../turn-navigation.js";
|
||||||
|
|
||||||
const mockUseEncounterContext = vi.mocked(useEncounterContext);
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
afterEach(() => {
|
writable: true,
|
||||||
cleanup();
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
vi.clearAllMocks();
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockContext(overrides: Partial<Encounter> = {}) {
|
afterEach(cleanup);
|
||||||
const encounter: Encounter = {
|
|
||||||
combatants: [
|
|
||||||
{ id: combatantId("1"), name: "Goblin" },
|
|
||||||
{ id: combatantId("2"), name: "Conjurer" },
|
|
||||||
],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
|
|
||||||
const value = {
|
function renderNav(encounter = buildEncounter()) {
|
||||||
encounter,
|
const adapters = createTestAdapters({ encounter });
|
||||||
advanceTurn: vi.fn(),
|
return render(<TurnNavigation />, {
|
||||||
retreatTurn: vi.fn(),
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
clearEncounter: vi.fn(),
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
isEmpty: encounter.combatants.length === 0,
|
),
|
||||||
hasCreatureCombatants: false,
|
});
|
||||||
canRollAllInitiative: false,
|
|
||||||
addCombatant: vi.fn(),
|
|
||||||
removeCombatant: vi.fn(),
|
|
||||||
editCombatant: vi.fn(),
|
|
||||||
setInitiative: vi.fn(),
|
|
||||||
setHp: vi.fn(),
|
|
||||||
adjustHp: vi.fn(),
|
|
||||||
setTempHp: vi.fn(),
|
|
||||||
hasTempHp: false,
|
|
||||||
setAc: vi.fn(),
|
|
||||||
toggleCondition: vi.fn(),
|
|
||||||
toggleConcentration: vi.fn(),
|
|
||||||
addFromBestiary: vi.fn(),
|
|
||||||
addMultipleFromBestiary: vi.fn(),
|
|
||||||
addFromPlayerCharacter: vi.fn(),
|
|
||||||
makeStore: vi.fn(),
|
|
||||||
withUndo: vi.fn((action: () => unknown) => action()),
|
|
||||||
undo: vi.fn(),
|
|
||||||
redo: vi.fn(),
|
|
||||||
canUndo: false,
|
|
||||||
canRedo: false,
|
|
||||||
undoRedoState: { undoStack: [], redoStack: [] },
|
|
||||||
setEncounter: vi.fn(),
|
|
||||||
setUndoRedoState: vi.fn(),
|
|
||||||
events: [],
|
|
||||||
lastCreatureId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
mockUseEncounterContext.mockReturnValue(
|
|
||||||
value as ReturnType<typeof useEncounterContext>,
|
|
||||||
);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderNav(overrides: Partial<Encounter> = {}) {
|
|
||||||
mockContext(overrides);
|
|
||||||
return render(<TurnNavigation />);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TurnNavigation", () => {
|
describe("TurnNavigation", () => {
|
||||||
describe("US1: Round badge and combatant name", () => {
|
describe("US1: Round badge and combatant name", () => {
|
||||||
it("renders the round badge with correct round number", () => {
|
it("renders the round badge with correct round number", () => {
|
||||||
renderNav({ roundNumber: 3 });
|
renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "Goblin" })],
|
||||||
|
roundNumber: 3,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
expect(screen.getByText("R3")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the combatant name separately from the round badge", () => {
|
it("renders the combatant name separately from the round badge", () => {
|
||||||
renderNav();
|
renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Goblin" }),
|
||||||
|
buildCombatant({ id: combatantId("c-2"), name: "Conjurer" }),
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const badge = screen.getByText("R1");
|
const badge = screen.getByText("R1");
|
||||||
const name = screen.getByText("Goblin");
|
const name = screen.getByText("Goblin");
|
||||||
expect(badge).toBeInTheDocument();
|
expect(badge).toBeInTheDocument();
|
||||||
@@ -104,41 +72,24 @@ describe("TurnNavigation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not render an em dash between round and name", () => {
|
it("does not render an em dash between round and name", () => {
|
||||||
const { container } = renderNav();
|
const { container } = renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "Goblin" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(container.textContent).not.toContain("\u2014");
|
expect(container.textContent).not.toContain("\u2014");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round badge and combatant name are siblings in the center area", () => {
|
it("round badge is in the left zone and name is in the center zone", () => {
|
||||||
renderNav();
|
renderNav(
|
||||||
|
buildEncounter({
|
||||||
|
combatants: [buildCombatant({ name: "Goblin" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
const badge = screen.getByText("R1");
|
const badge = screen.getByText("R1");
|
||||||
const name = screen.getByText("Goblin");
|
const name = screen.getByText("Goblin");
|
||||||
// badge text is inside inner span > outer span, name is a direct child
|
// Badge and name are in separate grid cells to prevent layout shifts
|
||||||
expect(badge.closest(".flex")).toBe(name.parentElement);
|
expect(badge.parentElement).not.toBe(name.parentElement);
|
||||||
});
|
|
||||||
|
|
||||||
it("updates the round badge when round changes", () => {
|
|
||||||
mockContext({ roundNumber: 2 });
|
|
||||||
const { rerender } = render(<TurnNavigation />);
|
|
||||||
expect(screen.getByText("R2")).toBeInTheDocument();
|
|
||||||
|
|
||||||
mockContext({ roundNumber: 3 });
|
|
||||||
rerender(<TurnNavigation />);
|
|
||||||
expect(screen.getByText("R3")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("R2")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the next combatant name when turn advances", () => {
|
|
||||||
const combatants = [
|
|
||||||
{ id: combatantId("1"), name: "Goblin" },
|
|
||||||
{ id: combatantId("2"), name: "Conjurer" },
|
|
||||||
];
|
|
||||||
mockContext({ combatants, activeIndex: 0 });
|
|
||||||
const { rerender } = render(<TurnNavigation />);
|
|
||||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
|
||||||
|
|
||||||
mockContext({ combatants, activeIndex: 1 });
|
|
||||||
rerender(<TurnNavigation />);
|
|
||||||
expect(screen.getByText("Conjurer")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,17 +97,21 @@ describe("TurnNavigation", () => {
|
|||||||
it("applies truncation styles to long combatant names", () => {
|
it("applies truncation styles to long combatant names", () => {
|
||||||
const longName =
|
const longName =
|
||||||
"Ancient Red Dragon Wyrm of the Northern Wastes and Beyond";
|
"Ancient Red Dragon Wyrm of the Northern Wastes and Beyond";
|
||||||
renderNav({
|
renderNav(
|
||||||
combatants: [{ id: combatantId("1"), name: longName }],
|
buildEncounter({
|
||||||
});
|
combatants: [buildCombatant({ name: longName })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
const nameEl = screen.getByText(longName);
|
const nameEl = screen.getByText(longName);
|
||||||
expect(nameEl.className).toContain("truncate");
|
expect(nameEl.className).toContain("truncate");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders three-zone layout with a single-character name", () => {
|
it("renders three-zone layout with a single-character name", () => {
|
||||||
renderNav({
|
renderNav(
|
||||||
combatants: [{ id: combatantId("1"), name: "O" }],
|
buildEncounter({
|
||||||
});
|
combatants: [buildCombatant({ name: "O" })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
expect(screen.getByText("O")).toBeInTheDocument();
|
expect(screen.getByText("O")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
@@ -169,9 +124,11 @@ describe("TurnNavigation", () => {
|
|||||||
|
|
||||||
it("keeps all action buttons accessible regardless of name length", () => {
|
it("keeps all action buttons accessible regardless of name length", () => {
|
||||||
const longName = "A".repeat(60);
|
const longName = "A".repeat(60);
|
||||||
renderNav({
|
renderNav(
|
||||||
combatants: [{ id: combatantId("1"), name: longName }],
|
buildEncounter({
|
||||||
});
|
combatants: [buildCombatant({ name: longName })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Previous turn" }),
|
screen.getByRole("button", { name: "Previous turn" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -182,29 +139,30 @@ describe("TurnNavigation", () => {
|
|||||||
|
|
||||||
it("renders a 40-character name without truncation class issues", () => {
|
it("renders a 40-character name without truncation class issues", () => {
|
||||||
const name40 = "A".repeat(40);
|
const name40 = "A".repeat(40);
|
||||||
renderNav({
|
renderNav(
|
||||||
combatants: [{ id: combatantId("1"), name: name40 }],
|
buildEncounter({
|
||||||
});
|
combatants: [buildCombatant({ name: name40 })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
const nameEl = screen.getByText(name40);
|
const nameEl = screen.getByText(name40);
|
||||||
expect(nameEl).toBeInTheDocument();
|
expect(nameEl).toBeInTheDocument();
|
||||||
// The truncate class is applied but CSS only visually truncates if content overflows
|
|
||||||
expect(nameEl.className).toContain("truncate");
|
expect(nameEl.className).toContain("truncate");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("US3: No combatants state", () => {
|
describe("US3: No combatants state", () => {
|
||||||
it("shows the round badge when there are no combatants", () => {
|
it("shows the round badge when there are no combatants", () => {
|
||||||
renderNav({ combatants: [], roundNumber: 1 });
|
renderNav(buildEncounter({ combatants: [], roundNumber: 1 }));
|
||||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows 'No combatants' placeholder text", () => {
|
it("shows 'No combatants' placeholder text", () => {
|
||||||
renderNav({ combatants: [] });
|
renderNav(buildEncounter({ combatants: [] }));
|
||||||
expect(screen.getByText("No combatants")).toBeInTheDocument();
|
expect(screen.getByText("No combatants")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables navigation buttons when there are no combatants", () => {
|
it("disables navigation buttons when there are no combatants", () => {
|
||||||
renderNav({ combatants: [] });
|
renderNav(buildEncounter({ combatants: [] }));
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Previous turn" }),
|
screen.getByRole("button", { name: "Previous turn" }),
|
||||||
).toBeDisabled();
|
).toBeDisabled();
|
||||||
|
|||||||
@@ -12,27 +12,20 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { type RefObject, useCallback, useRef, useState } from "react";
|
import React, { type RefObject, useCallback, useState } from "react";
|
||||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||||
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 { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
|
||||||
import {
|
import {
|
||||||
creatureKey,
|
creatureKey,
|
||||||
type QueuedCreature,
|
type QueuedCreature,
|
||||||
type SuggestionActions,
|
type SuggestionActions,
|
||||||
useActionBarState,
|
useActionBarState,
|
||||||
} from "../hooks/use-action-bar-state.js";
|
} from "../hooks/use-action-bar-state.js";
|
||||||
|
import { useEncounterExportImport } from "../hooks/use-encounter-export-import.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";
|
||||||
import {
|
|
||||||
assembleExportBundle,
|
|
||||||
bundleToJson,
|
|
||||||
readImportFile,
|
|
||||||
triggerDownload,
|
|
||||||
validateImportBundle,
|
|
||||||
} from "../persistence/export-import.js";
|
|
||||||
import { D20Icon } from "./d20-icon.js";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
import { ExportMethodDialog } from "./export-method-dialog.js";
|
import { ExportMethodDialog } from "./export-method-dialog.js";
|
||||||
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
|
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
|
||||||
@@ -439,116 +432,23 @@ export function ActionBar({
|
|||||||
} = useActionBarState();
|
} = useActionBarState();
|
||||||
|
|
||||||
const { state: bulkImportState } = useBulkImportContext();
|
const { state: bulkImportState } = useBulkImportContext();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
encounter,
|
importError,
|
||||||
undoRedoState,
|
showExportMethod,
|
||||||
isEmpty: encounterIsEmpty,
|
showImportMethod,
|
||||||
setEncounter,
|
showImportConfirm,
|
||||||
setUndoRedoState,
|
importFileRef,
|
||||||
} = useEncounterContext();
|
setImportError,
|
||||||
const { characters: playerCharacters, replacePlayerCharacters } =
|
setShowExportMethod,
|
||||||
usePlayerCharactersContext();
|
setShowImportMethod,
|
||||||
|
handleExportDownload,
|
||||||
const importFileRef = useRef<HTMLInputElement>(null);
|
handleExportClipboard,
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
handleImportFile,
|
||||||
const [showExportMethod, setShowExportMethod] = useState(false);
|
handleImportClipboard,
|
||||||
const [showImportMethod, setShowImportMethod] = useState(false);
|
handleImportConfirm,
|
||||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
handleImportCancel,
|
||||||
const pendingBundleRef = useRef<
|
} = useEncounterExportImport();
|
||||||
import("@initiative/domain").ExportBundle | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const handleExportDownload = useCallback(
|
|
||||||
(includeHistory: boolean, filename: string) => {
|
|
||||||
const bundle = assembleExportBundle(
|
|
||||||
encounter,
|
|
||||||
undoRedoState,
|
|
||||||
playerCharacters,
|
|
||||||
includeHistory,
|
|
||||||
);
|
|
||||||
triggerDownload(bundle, filename);
|
|
||||||
},
|
|
||||||
[encounter, undoRedoState, playerCharacters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleExportClipboard = useCallback(
|
|
||||||
(includeHistory: boolean) => {
|
|
||||||
const bundle = assembleExportBundle(
|
|
||||||
encounter,
|
|
||||||
undoRedoState,
|
|
||||||
playerCharacters,
|
|
||||||
includeHistory,
|
|
||||||
);
|
|
||||||
void navigator.clipboard.writeText(bundleToJson(bundle));
|
|
||||||
},
|
|
||||||
[encounter, undoRedoState, playerCharacters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const applyImport = useCallback(
|
|
||||||
(bundle: import("@initiative/domain").ExportBundle) => {
|
|
||||||
setEncounter(bundle.encounter);
|
|
||||||
setUndoRedoState({
|
|
||||||
undoStack: bundle.undoStack,
|
|
||||||
redoStack: bundle.redoStack,
|
|
||||||
});
|
|
||||||
replacePlayerCharacters([...bundle.playerCharacters]);
|
|
||||||
},
|
|
||||||
[setEncounter, setUndoRedoState, replacePlayerCharacters],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleValidatedBundle = useCallback(
|
|
||||||
(result: import("@initiative/domain").ExportBundle | string) => {
|
|
||||||
if (typeof result === "string") {
|
|
||||||
setImportError(result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (encounterIsEmpty) {
|
|
||||||
applyImport(result);
|
|
||||||
} else {
|
|
||||||
pendingBundleRef.current = result;
|
|
||||||
setShowImportConfirm(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[encounterIsEmpty, applyImport],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleImportFile = useCallback(
|
|
||||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (importFileRef.current) importFileRef.current.value = "";
|
|
||||||
|
|
||||||
setImportError(null);
|
|
||||||
handleValidatedBundle(await readImportFile(file));
|
|
||||||
},
|
|
||||||
[handleValidatedBundle],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleImportClipboard = useCallback(
|
|
||||||
(text: string) => {
|
|
||||||
setImportError(null);
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(text);
|
|
||||||
handleValidatedBundle(validateImportBundle(parsed));
|
|
||||||
} catch {
|
|
||||||
setImportError("Invalid file format");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleValidatedBundle],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleImportConfirm = useCallback(() => {
|
|
||||||
if (pendingBundleRef.current) {
|
|
||||||
applyImport(pendingBundleRef.current);
|
|
||||||
pendingBundleRef.current = null;
|
|
||||||
}
|
|
||||||
setShowImportConfirm(false);
|
|
||||||
}, [applyImport]);
|
|
||||||
|
|
||||||
const handleImportCancel = useCallback(() => {
|
|
||||||
pendingBundleRef.current = null;
|
|
||||||
setShowImportConfirm(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const overflowItems = buildOverflowItems({
|
const overflowItems = buildOverflowItems({
|
||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useId, useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.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/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/";
|
||||||
|
|
||||||
export function BulkImportPrompt() {
|
export function BulkImportPrompt() {
|
||||||
|
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 = 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,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type CombatantId,
|
type CombatantId,
|
||||||
type ConditionId,
|
type ConditionEntry,
|
||||||
type CreatureId,
|
type CreatureId,
|
||||||
deriveHpStatus,
|
deriveHpStatus,
|
||||||
type PlayerIcon,
|
type PlayerIcon,
|
||||||
@@ -31,7 +31,7 @@ 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 isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
readonly color?: string;
|
readonly color?: string;
|
||||||
readonly icon?: string;
|
readonly icon?: string;
|
||||||
@@ -430,7 +430,7 @@ function concentrationIconClass(
|
|||||||
dimmed: boolean,
|
dimmed: boolean,
|
||||||
): string {
|
): string {
|
||||||
if (!isConcentrating)
|
if (!isConcentrating)
|
||||||
return "opacity-0 group-hover:opacity-50 text-muted-foreground";
|
return "opacity-0 pointer-coarse:opacity-50 group-hover:opacity-50 text-muted-foreground";
|
||||||
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +448,8 @@ export function CombatantRow({
|
|||||||
setTempHp,
|
setTempHp,
|
||||||
setAc,
|
setAc,
|
||||||
toggleCondition,
|
toggleCondition,
|
||||||
|
setConditionValue,
|
||||||
|
decrementCondition,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
} = useEncounterContext();
|
} = useEncounterContext();
|
||||||
const { selectedCreatureId, showCreature, toggleCollapse } =
|
const { selectedCreatureId, showCreature, toggleCollapse } =
|
||||||
@@ -585,6 +587,7 @@ 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>
|
||||||
@@ -593,6 +596,9 @@ export function CombatantRow({
|
|||||||
anchorRef={conditionAnchorRef}
|
anchorRef={conditionAnchorRef}
|
||||||
activeConditions={combatant.conditions}
|
activeConditions={combatant.conditions}
|
||||||
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
|
onSetValue={(conditionId, value) =>
|
||||||
|
setConditionValue(id, conditionId, value)
|
||||||
|
}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
type ConditionEntry,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
getConditionsForEdition,
|
getConditionsForEdition,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
import { Check, Minus, Plus } from "lucide-react";
|
||||||
import { useLayoutEffect, useRef, useState } from "react";
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
@@ -16,8 +18,9 @@ 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;
|
||||||
onToggle: (conditionId: ConditionId) => void;
|
onToggle: (conditionId: ConditionId) => void;
|
||||||
|
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +28,7 @@ export function ConditionPicker({
|
|||||||
anchorRef,
|
anchorRef,
|
||||||
activeConditions,
|
activeConditions,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onSetValue,
|
||||||
onClose,
|
onClose,
|
||||||
}: Readonly<ConditionPickerProps>) {
|
}: Readonly<ConditionPickerProps>) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -34,6 +38,11 @@ export function ConditionPicker({
|
|||||||
maxHeight: number;
|
maxHeight: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<{
|
||||||
|
id: ConditionId;
|
||||||
|
value: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const anchor = anchorRef.current;
|
const anchor = anchorRef.current;
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
@@ -59,7 +68,9 @@ 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]),
|
||||||
|
);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -74,35 +85,112 @@ export function ConditionPicker({
|
|||||||
{conditions.map((def) => {
|
{conditions.map((def) => {
|
||||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const isActive = 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 (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={def.id}
|
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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,42 +1,74 @@
|
|||||||
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,
|
||||||
|
Footprints,
|
||||||
Gem,
|
Gem,
|
||||||
Ghost,
|
Ghost,
|
||||||
Hand,
|
Hand,
|
||||||
Heart,
|
Heart,
|
||||||
|
HeartCrack,
|
||||||
|
HeartPulse,
|
||||||
Link,
|
Link,
|
||||||
Moon,
|
Moon,
|
||||||
|
PersonStanding,
|
||||||
ShieldMinus,
|
ShieldMinus,
|
||||||
|
ShieldOff,
|
||||||
Siren,
|
Siren,
|
||||||
|
Skull,
|
||||||
Snail,
|
Snail,
|
||||||
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,
|
||||||
|
Footprints,
|
||||||
|
Gem,
|
||||||
|
Ghost,
|
||||||
|
Hand,
|
||||||
|
Heart,
|
||||||
|
HeartCrack,
|
||||||
|
HeartPulse,
|
||||||
Link,
|
Link,
|
||||||
|
Moon,
|
||||||
|
PersonStanding,
|
||||||
ShieldMinus,
|
ShieldMinus,
|
||||||
|
ShieldOff,
|
||||||
|
Siren,
|
||||||
|
Skull,
|
||||||
Snail,
|
Snail,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Moon,
|
Sun,
|
||||||
|
TrendingDown,
|
||||||
|
Zap,
|
||||||
|
ZapOff,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||||
@@ -51,4 +83,5 @@ export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
|||||||
green: "text-green-400",
|
green: "text-green-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>
|
||||||
);
|
);
|
||||||
|
|||||||
36
apps/web/src/components/cr-picker.tsx
Normal file
36
apps/web/src/components/cr-picker.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { VALID_CR_VALUES } from "@initiative/domain";
|
||||||
|
|
||||||
|
const CR_LABELS: Record<string, string> = {
|
||||||
|
"0": "CR 0",
|
||||||
|
"1/8": "CR 1/8",
|
||||||
|
"1/4": "CR 1/4",
|
||||||
|
"1/2": "CR 1/2",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatCr(cr: string): string {
|
||||||
|
return CR_LABELS[cr] ?? `CR ${cr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string | null;
|
||||||
|
onChange: (cr: string | undefined) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="rounded border border-border bg-card px-1.5 py-0.5 text-xs"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value || undefined)}
|
||||||
|
aria-label="Challenge rating"
|
||||||
|
>
|
||||||
|
<option value="">Assign</option>
|
||||||
|
{VALID_CR_VALUES.map((cr) => (
|
||||||
|
<option key={cr} value={cr}>
|
||||||
|
{formatCr(cr)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
apps/web/src/components/difficulty-breakdown-panel.tsx
Normal file
229
apps/web/src/components/difficulty-breakdown-panel.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import type { DifficultyTier, RulesEdition } from "@initiative/domain";
|
||||||
|
import { ArrowLeftRight } from "lucide-react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
|
import {
|
||||||
|
type BreakdownCombatant,
|
||||||
|
useDifficultyBreakdown,
|
||||||
|
} from "../hooks/use-difficulty-breakdown.js";
|
||||||
|
import { CrPicker } from "./cr-picker.js";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
|
const TIER_LABEL_MAP: Partial<
|
||||||
|
Record<RulesEdition, Record<DifficultyTier, { label: string; color: string }>>
|
||||||
|
> = {
|
||||||
|
"5.5e": {
|
||||||
|
0: { label: "Trivial", color: "text-muted-foreground" },
|
||||||
|
1: { label: "Low", color: "text-green-500" },
|
||||||
|
2: { label: "Moderate", color: "text-yellow-500" },
|
||||||
|
3: { label: "High", color: "text-red-500" },
|
||||||
|
},
|
||||||
|
"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 {
|
||||||
|
return xp.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function PcRow({ entry }: { entry: BreakdownCombatant }) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NpcRow({
|
||||||
|
entry,
|
||||||
|
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 (
|
||||||
|
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
|
||||||
|
<span className="min-w-0 truncate" title={entry.combatant.name}>
|
||||||
|
{entry.combatant.name}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onToggleSide}
|
||||||
|
aria-label={`Move ${entry.combatant.name} to ${targetSide} side`}
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
{entry.editable ? (
|
||||||
|
<CrPicker
|
||||||
|
value={entry.cr}
|
||||||
|
onChange={(cr) => setCr(entry.combatant.id, cr)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{entry.cr ? `CR ${entry.cr}` : "\u2014"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-right tabular-nums">{xpDisplay}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useClickOutside(ref, onClose);
|
||||||
|
const { setSide } = useEncounterContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
|
||||||
|
const breakdown = useDifficultyBreakdown();
|
||||||
|
if (!breakdown) return null;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
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">
|
||||||
|
Encounter Difficulty:{" "}
|
||||||
|
<span className={tierConfig.color}>{tierConfig.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 border-border border-t pt-2">
|
||||||
|
<div className="mb-1 text-muted-foreground text-xs">
|
||||||
|
Party Budget ({breakdown.pcCount}{" "}
|
||||||
|
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 text-xs">
|
||||||
|
{breakdown.thresholds.map((t) => (
|
||||||
|
<span key={t.label}>
|
||||||
|
{shortLabel(t.label)}: <strong>{formatXp(t.value)}</strong>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</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="mb-1 flex justify-between text-muted-foreground text-xs">
|
||||||
|
<span>Party</span>
|
||||||
|
<span>XP</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto_3.5rem] gap-x-2 gap-y-1">
|
||||||
|
{breakdown.partyCombatants.map((entry) =>
|
||||||
|
isPC(entry) ? (
|
||||||
|
<PcRow key={entry.combatant.id} entry={entry} />
|
||||||
|
) : (
|
||||||
|
<NpcRow
|
||||||
|
key={entry.combatant.id}
|
||||||
|
entry={entry}
|
||||||
|
onToggleSide={() => handleToggle(entry)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
<span>Net Monster XP</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatXp(breakdown.totalMonsterXp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,58 @@
|
|||||||
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({ result }: { result: DifficultyResult }) {
|
export function DifficultyIndicator({
|
||||||
const config = TIER_CONFIG[result.tier];
|
result,
|
||||||
const tooltip = `${config.label} encounter difficulty`;
|
labels,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
result: DifficultyResult;
|
||||||
|
labels: Record<DifficultyTier, string>;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) {
|
||||||
|
const config = TIER_COLORS[result.tier];
|
||||||
|
const label = labels[result.tier];
|
||||||
|
const tooltip = `${label} encounter difficulty`;
|
||||||
|
|
||||||
|
const Element = onClick ? "button" : "div";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Element
|
||||||
className="flex items-end gap-0.5"
|
className={cn(
|
||||||
|
"flex items-end gap-0.5",
|
||||||
|
onClick && "cursor-pointer rounded p-1 hover:bg-muted/50",
|
||||||
|
)}
|
||||||
title={tooltip}
|
title={tooltip}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={tooltip}
|
aria-label={tooltip}
|
||||||
|
onClick={onClick}
|
||||||
|
type={onClick ? "button" : undefined}
|
||||||
>
|
>
|
||||||
{BAR_HEIGHTS.map((height, i) => (
|
{BAR_HEIGHTS.map((height, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -34,6 +64,6 @@ export function DifficultyIndicator({ result }: { result: DifficultyResult }) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Element>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
143
apps/web/src/components/pf2e-stat-block.tsx
Normal file
143
apps/web/src/components/pf2e-stat-block.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import type { Pf2eCreature } from "@initiative/domain";
|
||||||
|
import { formatInitiativeModifier } from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
PropertyLine,
|
||||||
|
SectionDivider,
|
||||||
|
TraitSection,
|
||||||
|
} from "./stat-block-parts.js";
|
||||||
|
|
||||||
|
interface Pf2eStatBlockProps {
|
||||||
|
creature: Pf2eCreature;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||||
|
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="font-bold text-stat-heading text-xl">
|
||||||
|
{creature.name}
|
||||||
|
</h2>
|
||||||
|
<span className="shrink-0 font-semibold text-sm">
|
||||||
|
Level {creature.level}
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionDivider />
|
||||||
|
|
||||||
|
{/* Perception, Languages, Skills */}
|
||||||
|
<div className="space-y-0.5 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Perception</span>{" "}
|
||||||
|
{formatInitiativeModifier(creature.perception)}
|
||||||
|
{creature.senses ? `; ${creature.senses}` : ""}
|
||||||
|
</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>
|
||||||
|
<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)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">HP</span> {creature.hp}
|
||||||
|
</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} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useId, useRef, useState } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import {
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
getDefaultFetchUrl,
|
|
||||||
getSourceDisplayName,
|
|
||||||
} from "../adapters/bestiary-index-adapter.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";
|
||||||
|
|
||||||
@@ -17,9 +15,14 @@ export function SourceFetchPrompt({
|
|||||||
sourceCode,
|
sourceCode,
|
||||||
onSourceLoaded,
|
onSourceLoaded,
|
||||||
}: Readonly<SourceFetchPromptProps>) {
|
}: Readonly<SourceFetchPromptProps>) {
|
||||||
|
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||||
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
||||||
const sourceDisplayName = getSourceDisplayName(sourceCode);
|
const { edition } = useRulesEditionContext();
|
||||||
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
||||||
|
const sourceDisplayName = indexPort.getSourceDisplayName(sourceCode);
|
||||||
|
const [url, setUrl] = useState(() =>
|
||||||
|
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>("");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ import {
|
|||||||
useOptimistic,
|
useOptimistic,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
import type { CachedSourceInfo } from "../adapters/ports.js";
|
||||||
import * as bestiaryCache from "../adapters/bestiary-cache.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 { 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(
|
||||||
@@ -28,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, system]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadSources();
|
void loadSources();
|
||||||
@@ -38,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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CreatureId } from "@initiative/domain";
|
import type { Creature, CreatureId } 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";
|
||||||
@@ -7,9 +7,10 @@ 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 { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
interface StatBlockPanelProps {
|
interface StatBlockPanelProps {
|
||||||
@@ -307,7 +308,10 @@ export function StatBlockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (creature) {
|
if (creature) {
|
||||||
return <StatBlock creature={creature} />;
|
if ("system" in creature && creature.system === "pf2e") {
|
||||||
|
return <Pf2eStatBlock creature={creature} />;
|
||||||
|
}
|
||||||
|
return <DndStatBlock creature={creature as Creature} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsFetch && sourceCode) {
|
if (needsFetch && sourceCode) {
|
||||||
|
|||||||
90
apps/web/src/components/stat-block-parts.tsx
Normal file
90
apps/web/src/components/stat-block-parts.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import type { TraitBlock, TraitSegment } from "@initiative/domain";
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<span key={segmentKey(seg)}>
|
||||||
|
{i === 0 ? ` ${seg.value}` : seg.value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
|
||||||
|
{seg.items.map((item) => (
|
||||||
|
<p key={item.label ?? item.text}>
|
||||||
|
{item.label != null && (
|
||||||
|
<span className="font-semibold">{item.label}. </span>
|
||||||
|
)}
|
||||||
|
{item.text}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-semibold italic">{trait.name}.</span>
|
||||||
|
<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,7 +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 { 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 { DifficultyIndicator } from "./difficulty-indicator.js";
|
import { DifficultyBreakdownPanel } from "./difficulty-breakdown-panel.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";
|
||||||
|
|
||||||
@@ -18,12 +25,17 @@ 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 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;
|
||||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-glow flex items-center gap-3 border-border border-b bg-card px-4 py-3 sm:rounded-lg sm:border">
|
<div className="card-glow grid grid-cols-[1fr_minmax(0,auto)_1fr] items-center border-border border-b bg-card px-2 py-3 sm:rounded-lg sm:border sm:px-4">
|
||||||
|
{/* Left zone: navigation + history + round */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -34,8 +46,6 @@ export function TurnNavigation() {
|
|||||||
>
|
>
|
||||||
<StepBack className="h-5 w-5" />
|
<StepBack className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -56,23 +66,36 @@ export function TurnNavigation() {
|
|||||||
>
|
>
|
||||||
<Redo2 className="h-4 w-4" />
|
<Redo2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<span className="ml-1 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm tabular-nums">
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
|
||||||
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
|
||||||
<span className="-mt-[3px] inline-block">
|
|
||||||
R{encounter.roundNumber}
|
R{encounter.roundNumber}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
|
|
||||||
|
{/* Center zone: active combatant name */}
|
||||||
|
<div className="min-w-0 px-2 text-center text-sm">
|
||||||
{activeCombatant ? (
|
{activeCombatant ? (
|
||||||
<span className="truncate font-medium">{activeCombatant.name}</span>
|
<span className="truncate font-medium">{activeCombatant.name}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">No combatants</span>
|
<span className="text-muted-foreground">No combatants</span>
|
||||||
)}
|
)}
|
||||||
{difficulty && <DifficultyIndicator result={difficulty} />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-3">
|
{/* Right zone: difficulty + destructive + forward */}
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{difficulty && (
|
||||||
|
<div className="relative mr-1">
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={difficulty}
|
||||||
|
labels={tierLabels}
|
||||||
|
onClick={() => setShowBreakdown((prev) => !prev)}
|
||||||
|
/>
|
||||||
|
{showBreakdown ? (
|
||||||
|
<DifficultyBreakdownPanel
|
||||||
|
onClose={() => setShowBreakdown(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<Trash2 className="h-5 w-5" />}
|
icon={<Trash2 className="h-5 w-5" />}
|
||||||
label="Clear encounter"
|
label="Clear encounter"
|
||||||
|
|||||||
40
apps/web/src/contexts/adapter-context.tsx
Normal file
40
apps/web/src/contexts/adapter-context.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
|
import type {
|
||||||
|
BestiaryCachePort,
|
||||||
|
BestiaryIndexPort,
|
||||||
|
EncounterPersistence,
|
||||||
|
Pf2eBestiaryIndexPort,
|
||||||
|
PlayerCharacterPersistence,
|
||||||
|
UndoRedoPersistence,
|
||||||
|
} from "../adapters/ports.js";
|
||||||
|
|
||||||
|
export interface Adapters {
|
||||||
|
encounterPersistence: EncounterPersistence;
|
||||||
|
undoRedoPersistence: UndoRedoPersistence;
|
||||||
|
playerCharacterPersistence: PlayerCharacterPersistence;
|
||||||
|
bestiaryCache: BestiaryCachePort;
|
||||||
|
bestiaryIndex: BestiaryIndexPort;
|
||||||
|
pf2eBestiaryIndex: Pf2eBestiaryIndexPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdapterContext = createContext<Adapters | null>(null);
|
||||||
|
|
||||||
|
export function AdapterProvider({
|
||||||
|
adapters,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
adapters: Adapters;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AdapterContext.Provider value={adapters}>
|
||||||
|
{children}
|
||||||
|
</AdapterContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdapters(): Adapters {
|
||||||
|
const ctx = useContext(AdapterContext);
|
||||||
|
if (!ctx) throw new Error("useAdapters requires AdapterProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -10,19 +6,10 @@ import {
|
|||||||
isDomainError,
|
isDomainError,
|
||||||
playerCharacterId,
|
playerCharacterId,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { describe, expect, it, vi } 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";
|
||||||
|
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
|
||||||
loadEncounter: vi.fn().mockReturnValue(null),
|
|
||||||
saveEncounter: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../persistence/undo-redo-storage.js", () => ({
|
|
||||||
loadUndoRedoStacks: vi.fn().mockReturnValue(EMPTY_UNDO_REDO_STATE),
|
|
||||||
saveUndoRedoStacks: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function emptyState(): EncounterState {
|
function emptyState(): EncounterState {
|
||||||
return {
|
return {
|
||||||
encounter: {
|
encounter: {
|
||||||
@@ -55,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,
|
||||||
@@ -67,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", () => {
|
||||||
@@ -246,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", () => {
|
||||||
@@ -337,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", () => {
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import type { PlayerCharacter } from "@initiative/domain";
|
|
||||||
import { playerCharacterId } from "@initiative/domain";
|
|
||||||
import { act, renderHook } from "@testing-library/react";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { SearchResult } from "../../contexts/bestiary-context.js";
|
|
||||||
import { useActionBarState } from "../use-action-bar-state.js";
|
|
||||||
|
|
||||||
const mockAddCombatant = vi.fn();
|
|
||||||
const mockAddFromBestiary = vi.fn();
|
|
||||||
const mockAddMultipleFromBestiary = vi.fn();
|
|
||||||
const mockAddFromPlayerCharacter = vi.fn();
|
|
||||||
const mockBestiarySearch = vi.fn<(q: string) => SearchResult[]>();
|
|
||||||
const mockShowCreature = vi.fn();
|
|
||||||
const mockShowBulkImport = vi.fn();
|
|
||||||
const mockShowSourceManager = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../../contexts/encounter-context.js", () => ({
|
|
||||||
useEncounterContext: () => ({
|
|
||||||
addCombatant: mockAddCombatant,
|
|
||||||
addFromBestiary: mockAddFromBestiary,
|
|
||||||
addMultipleFromBestiary: mockAddMultipleFromBestiary,
|
|
||||||
addFromPlayerCharacter: mockAddFromPlayerCharacter,
|
|
||||||
lastCreatureId: null,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../contexts/bestiary-context.js", () => ({
|
|
||||||
useBestiaryContext: () => ({
|
|
||||||
search: mockBestiarySearch,
|
|
||||||
isLoaded: true,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../contexts/player-characters-context.js", () => ({
|
|
||||||
usePlayerCharactersContext: () => ({
|
|
||||||
characters: mockPlayerCharacters,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../../contexts/side-panel-context.js", () => ({
|
|
||||||
useSidePanelContext: () => ({
|
|
||||||
showCreature: mockShowCreature,
|
|
||||||
showBulkImport: mockShowBulkImport,
|
|
||||||
showSourceManager: mockShowSourceManager,
|
|
||||||
panelView: { mode: "closed" },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let mockPlayerCharacters: PlayerCharacter[] = [];
|
|
||||||
|
|
||||||
const GOBLIN: SearchResult = {
|
|
||||||
name: "Goblin",
|
|
||||||
source: "MM",
|
|
||||||
sourceDisplayName: "Monster Manual",
|
|
||||||
ac: 15,
|
|
||||||
hp: 7,
|
|
||||||
dex: 14,
|
|
||||||
cr: "1/4",
|
|
||||||
initiativeProficiency: 0,
|
|
||||||
size: "Small",
|
|
||||||
type: "humanoid",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ORC: SearchResult = {
|
|
||||||
name: "Orc",
|
|
||||||
source: "MM",
|
|
||||||
sourceDisplayName: "Monster Manual",
|
|
||||||
ac: 13,
|
|
||||||
hp: 15,
|
|
||||||
dex: 12,
|
|
||||||
cr: "1/2",
|
|
||||||
initiativeProficiency: 0,
|
|
||||||
size: "Medium",
|
|
||||||
type: "humanoid",
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderActionBar() {
|
|
||||||
return renderHook(() => useActionBarState());
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("useActionBarState", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockBestiarySearch.mockReturnValue([]);
|
|
||||||
mockPlayerCharacters = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("search and suggestions", () => {
|
|
||||||
it("starts with empty state", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
expect(result.current.nameInput).toBe("");
|
|
||||||
expect(result.current.suggestions).toEqual([]);
|
|
||||||
expect(result.current.queued).toBeNull();
|
|
||||||
expect(result.current.browseMode).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("searches bestiary when input >= 2 chars", () => {
|
|
||||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("go"));
|
|
||||||
|
|
||||||
expect(mockBestiarySearch).toHaveBeenCalledWith("go");
|
|
||||||
expect(result.current.nameInput).toBe("go");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not search when input < 2 chars", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("g"));
|
|
||||||
|
|
||||||
expect(mockBestiarySearch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches player characters by name", () => {
|
|
||||||
mockPlayerCharacters = [
|
|
||||||
{
|
|
||||||
id: playerCharacterId("pc-1"),
|
|
||||||
name: "Gandalf",
|
|
||||||
ac: 15,
|
|
||||||
maxHp: 40,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
mockBestiarySearch.mockReturnValue([]);
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("gan"));
|
|
||||||
|
|
||||||
expect(result.current.pcMatches).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("queued creatures", () => {
|
|
||||||
it("queues a creature on click", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
|
|
||||||
expect(result.current.queued).toEqual({
|
|
||||||
result: GOBLIN,
|
|
||||||
count: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("increments count when same creature clicked again", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
|
|
||||||
expect(result.current.queued?.count).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resets queue when different creature clicked", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(ORC));
|
|
||||||
|
|
||||||
expect(result.current.queued).toEqual({
|
|
||||||
result: ORC,
|
|
||||||
count: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("confirmQueued calls addFromBestiary for count=1", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.confirmQueued());
|
|
||||||
|
|
||||||
expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN);
|
|
||||||
expect(result.current.queued).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("confirmQueued calls addMultipleFromBestiary for count>1", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.confirmQueued());
|
|
||||||
|
|
||||||
expect(mockAddMultipleFromBestiary).toHaveBeenCalledWith(GOBLIN, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears queued when search text changes and creature no longer visible", () => {
|
|
||||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("go"));
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
|
|
||||||
// Change search to something that won't match
|
|
||||||
mockBestiarySearch.mockReturnValue([]);
|
|
||||||
act(() => result.current.handleNameChange("xyz"));
|
|
||||||
|
|
||||||
expect(result.current.queued).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("form submission", () => {
|
|
||||||
it("adds custom combatant on submit", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("Fighter"));
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
preventDefault: vi.fn(),
|
|
||||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
|
||||||
act(() => result.current.handleAdd(event));
|
|
||||||
|
|
||||||
expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", undefined);
|
|
||||||
expect(result.current.nameInput).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not add when name is empty", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
preventDefault: vi.fn(),
|
|
||||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
|
||||||
act(() => result.current.handleAdd(event));
|
|
||||||
|
|
||||||
expect(mockAddCombatant).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes custom init/ac/maxHp when set", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("Fighter"));
|
|
||||||
act(() => result.current.setCustomInit("15"));
|
|
||||||
act(() => result.current.setCustomAc("18"));
|
|
||||||
act(() => result.current.setCustomMaxHp("45"));
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
preventDefault: vi.fn(),
|
|
||||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
|
||||||
act(() => result.current.handleAdd(event));
|
|
||||||
|
|
||||||
expect(mockAddCombatant).toHaveBeenCalledWith("Fighter", {
|
|
||||||
initiative: 15,
|
|
||||||
ac: 18,
|
|
||||||
maxHp: 45,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not submit in browse mode", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.toggleBrowseMode());
|
|
||||||
act(() => result.current.handleNameChange("Fighter"));
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
preventDefault: vi.fn(),
|
|
||||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
|
||||||
act(() => result.current.handleAdd(event));
|
|
||||||
|
|
||||||
expect(mockAddCombatant).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("confirms queued on submit instead of adding by name", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
|
|
||||||
const event = {
|
|
||||||
preventDefault: vi.fn(),
|
|
||||||
} as unknown as React.SubmitEvent<HTMLFormElement>;
|
|
||||||
act(() => result.current.handleAdd(event));
|
|
||||||
|
|
||||||
expect(mockAddFromBestiary).toHaveBeenCalledWith(GOBLIN);
|
|
||||||
expect(mockAddCombatant).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("browse mode", () => {
|
|
||||||
it("toggles browse mode", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.toggleBrowseMode());
|
|
||||||
expect(result.current.browseMode).toBe(true);
|
|
||||||
|
|
||||||
act(() => result.current.toggleBrowseMode());
|
|
||||||
expect(result.current.browseMode).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handleBrowseSelect shows creature and exits browse mode", () => {
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.toggleBrowseMode());
|
|
||||||
act(() => result.current.handleBrowseSelect(GOBLIN));
|
|
||||||
|
|
||||||
expect(mockShowCreature).toHaveBeenCalledWith("mm:goblin");
|
|
||||||
expect(result.current.browseMode).toBe(false);
|
|
||||||
expect(result.current.nameInput).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("dismiss and clear", () => {
|
|
||||||
it("dismissSuggestions clears suggestions and queued", () => {
|
|
||||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("go"));
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.dismiss());
|
|
||||||
|
|
||||||
expect(result.current.queued).toBeNull();
|
|
||||||
expect(result.current.suggestionIndex).toBe(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clear resets everything", () => {
|
|
||||||
mockBestiarySearch.mockReturnValue([GOBLIN]);
|
|
||||||
const { result } = renderActionBar();
|
|
||||||
|
|
||||||
act(() => result.current.handleNameChange("go"));
|
|
||||||
act(() => result.current.suggestionActions.clickSuggestion(GOBLIN));
|
|
||||||
act(() => result.current.suggestionActions.clear());
|
|
||||||
|
|
||||||
expect(result.current.nameInput).toBe("");
|
|
||||||
expect(result.current.queued).toBeNull();
|
|
||||||
expect(result.current.suggestionIndex).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,15 +1,38 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { useBulkImport } from "../use-bulk-import.js";
|
import { useBulkImport } from "../use-bulk-import.js";
|
||||||
|
|
||||||
vi.mock("../../adapters/bestiary-index-adapter.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 adapters = createTestAdapters();
|
||||||
|
adapters.bestiaryIndex = {
|
||||||
|
...adapters.bestiaryIndex,
|
||||||
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
getAllSourceCodes: () => ["MM", "VGM", "XGE"],
|
||||||
getDefaultFetchUrl: (code: string, baseUrl: string) =>
|
getDefaultFetchUrl: (code: string, baseUrl?: string) =>
|
||||||
`${baseUrl}${code}.json`,
|
`${baseUrl}${code}.json`,
|
||||||
loadBestiaryIndex: () => ({ sources: {}, creatures: [] }),
|
};
|
||||||
getSourceDisplayName: (code: string) => code,
|
|
||||||
}));
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <AllProviders adapters={adapters}>{children}</AllProviders>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Flush microtasks so the internal async IIFE inside startImport settles. */
|
/** Flush microtasks so the internal async IIFE inside startImport settles. */
|
||||||
function flushMicrotasks(): Promise<void> {
|
function flushMicrotasks(): Promise<void> {
|
||||||
@@ -20,7 +43,7 @@ function flushMicrotasks(): Promise<void> {
|
|||||||
|
|
||||||
describe("useBulkImport", () => {
|
describe("useBulkImport", () => {
|
||||||
it("starts in idle state with all counters at 0", () => {
|
it("starts in idle state with all counters at 0", () => {
|
||||||
const { result } = renderHook(() => useBulkImport());
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
expect(result.current.state).toEqual({
|
expect(result.current.state).toEqual({
|
||||||
status: "idle",
|
status: "idle",
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -30,7 +53,7 @@ describe("useBulkImport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reset returns to idle state", async () => {
|
it("reset returns to idle state", async () => {
|
||||||
const { result } = renderHook(() => useBulkImport());
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
const isSourceCached = vi.fn().mockResolvedValue(true);
|
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||||
const fetchAndCacheSource = vi.fn();
|
const fetchAndCacheSource = vi.fn();
|
||||||
@@ -51,7 +74,7 @@ describe("useBulkImport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("goes straight to complete when all sources are cached", async () => {
|
it("goes straight to complete when all sources are cached", async () => {
|
||||||
const { result } = renderHook(() => useBulkImport());
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
const isSourceCached = vi.fn().mockResolvedValue(true);
|
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||||
const fetchAndCacheSource = vi.fn();
|
const fetchAndCacheSource = vi.fn();
|
||||||
@@ -73,7 +96,7 @@ describe("useBulkImport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fetches uncached sources and completes", async () => {
|
it("fetches uncached sources and completes", async () => {
|
||||||
const { result } = renderHook(() => useBulkImport());
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
||||||
@@ -97,7 +120,7 @@ describe("useBulkImport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reports partial-failure when some sources fail", async () => {
|
it("reports partial-failure when some sources fail", async () => {
|
||||||
const { result } = renderHook(() => useBulkImport());
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
const fetchAndCacheSource = vi
|
const fetchAndCacheSource = vi
|
||||||
@@ -124,7 +147,7 @@ describe("useBulkImport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls refreshCache after all batches complete", async () => {
|
it("calls refreshCache after all batches complete", async () => {
|
||||||
const { result } = renderHook(() => useBulkImport());
|
const { result } = renderHook(() => useBulkImport(), { wrapper });
|
||||||
|
|
||||||
const isSourceCached = vi.fn().mockResolvedValue(false);
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
const fetchAndCacheSource = vi.fn().mockResolvedValue(undefined);
|
||||||
348
apps/web/src/hooks/__tests__/use-difficulty-breakdown.test.tsx
Normal file
348
apps/web/src/hooks/__tests__/use-difficulty-breakdown.test.tsx
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
// @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 { useDifficultyBreakdown } from "../use-difficulty-breakdown.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 goblinCreature = buildCreature({
|
||||||
|
id: creatureId("srd:goblin"),
|
||||||
|
name: "Goblin",
|
||||||
|
cr: "1/4",
|
||||||
|
source: "srd",
|
||||||
|
sourceDisplayName: "SRD",
|
||||||
|
});
|
||||||
|
|
||||||
|
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("useDifficultyBreakdown", () => {
|
||||||
|
it("returns null when no leveled PCs", () => {
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no monsters with CR", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Custom",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns per-combatant entries split by side", 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: "Custom Thug",
|
||||||
|
cr: "2",
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-4"),
|
||||||
|
name: "Bandit",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const breakdown = result.current;
|
||||||
|
expect(breakdown).not.toBeNull();
|
||||||
|
expect(breakdown?.pcCount).toBe(1);
|
||||||
|
// CR 1/4 = 50 + CR 2 = 450 -> total 500
|
||||||
|
expect(breakdown?.totalMonsterXp).toBe(500);
|
||||||
|
|
||||||
|
// PC in party column
|
||||||
|
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?.xp).toBe(50);
|
||||||
|
expect(goblin?.source).toBe("SRD");
|
||||||
|
expect(goblin?.editable).toBe(false);
|
||||||
|
expect(goblin?.side).toBe("enemy");
|
||||||
|
|
||||||
|
const thug = breakdown?.enemyCombatants[1];
|
||||||
|
expect(thug?.cr).toBe("2");
|
||||||
|
expect(thug?.xp).toBe(450);
|
||||||
|
expect(thug?.source).toBeNull();
|
||||||
|
expect(thug?.editable).toBe(true);
|
||||||
|
|
||||||
|
const bandit = breakdown?.enemyCombatants[2];
|
||||||
|
expect(bandit?.cr).toBeNull();
|
||||||
|
expect(bandit?.xp).toBeNull();
|
||||||
|
expect(bandit?.editable).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bestiary combatant with missing creature is non-editable with null CR", () => {
|
||||||
|
const missingCreatureId = creatureId("creature-missing");
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Ghost",
|
||||||
|
creatureId: missingCreatureId,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-3"),
|
||||||
|
name: "Thug",
|
||||||
|
cr: "1",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
const breakdown = result.current;
|
||||||
|
expect(breakdown).not.toBeNull();
|
||||||
|
const ghost = breakdown?.enemyCombatants[0];
|
||||||
|
expect(ghost?.cr).toBeNull();
|
||||||
|
expect(ghost?.xp).toBeNull();
|
||||||
|
expect(ghost?.editable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PC combatants appear in partyCombatants with level", 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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current?.partyCombatants).toHaveLength(1);
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
173
apps/web/src/hooks/__tests__/use-difficulty-custom-cr.test.tsx
Normal file
173
apps/web/src/hooks/__tests__/use-difficulty-custom-cr.test.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// @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";
|
||||||
|
|
||||||
|
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 goblinCreature = buildCreature({
|
||||||
|
id: creatureId("srd:goblin"),
|
||||||
|
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 with custom combatant CRs", () => {
|
||||||
|
it("includes custom combatant with cr field in monster XP", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Custom Thug",
|
||||||
|
cr: "2",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(450);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses bestiary CR when combatant has both creatureId and cr", 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,
|
||||||
|
cr: "5",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// Should use bestiary CR 1/4 (50 XP), not the manual cr "5" (1800 XP)
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mixes bestiary and custom-with-CR combatants correctly", 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: "Custom",
|
||||||
|
cr: "1",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[goblinCreature.id, goblinCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// CR 1/4 = 50 XP, CR 1 = 200 XP → total 250
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(250);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("custom combatant without CR is still excluded", () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Custom Monster",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { ExportBundle } from "@initiative/domain";
|
||||||
|
import { combatantId, playerCharacterId } from "@initiative/domain";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import {
|
||||||
|
buildCombatant,
|
||||||
|
buildEncounter,
|
||||||
|
} from "../../__tests__/factories/index.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
|
import { useEncounterContext } from "../../contexts/encounter-context.js";
|
||||||
|
import { useEncounterExportImport } from "../use-encounter-export-import.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(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <AllProviders>{children}</AllProviders>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapperWithEncounter(encounter: ReturnType<typeof buildEncounter>) {
|
||||||
|
const adapters = createTestAdapters({ encounter });
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <AllProviders adapters={adapters}>{children}</AllProviders>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_BUNDLE: ExportBundle = {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [buildCombatant({ id: combatantId("c-1"), name: "Imported" })],
|
||||||
|
}),
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
playerCharacters: [
|
||||||
|
{
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Hero",
|
||||||
|
ac: 16,
|
||||||
|
maxHp: 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useEncounterExportImport", () => {
|
||||||
|
describe("import via clipboard", () => {
|
||||||
|
it("imports valid JSON into empty encounter without error", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard(JSON.stringify(VALID_BUNDLE));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import should succeed without error and not show confirm
|
||||||
|
expect(result.current.importError).toBeNull();
|
||||||
|
expect(result.current.showImportConfirm).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets error for invalid JSON", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard("not json{{{");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.importError).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets error for valid JSON that fails validation", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard(JSON.stringify({ version: 999 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.importError).toBe("Invalid file format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows confirm dialog when encounter is not empty", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Existing" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper: wrapperWithEncounter(encounter),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard(JSON.stringify(VALID_BUNDLE));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showImportConfirm).toBe(true);
|
||||||
|
expect(result.current.importError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleImportConfirm clears confirm dialog", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Existing" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper: wrapperWithEncounter(encounter),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard(JSON.stringify(VALID_BUNDLE));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showImportConfirm).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showImportConfirm).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleImportCancel clears pending without applying", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Existing" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => ({
|
||||||
|
exportImport: useEncounterExportImport(),
|
||||||
|
encounter: useEncounterContext(),
|
||||||
|
}),
|
||||||
|
{ wrapper: wrapperWithEncounter(encounter) },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.exportImport.handleImportClipboard(
|
||||||
|
JSON.stringify(VALID_BUNDLE),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.exportImport.handleImportCancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.exportImport.showImportConfirm).toBe(false);
|
||||||
|
expect(result.current.encounter.encounter.combatants[0].name).toBe(
|
||||||
|
"Existing",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("export", () => {
|
||||||
|
it("handleExportDownload calls triggerDownload", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({ id: combatantId("c-1"), name: "Fighter" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper: wrapperWithEncounter(encounter),
|
||||||
|
});
|
||||||
|
|
||||||
|
// triggerDownload creates a blob URL and clicks an anchor — just verify it doesn't throw
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
result.current.handleExportDownload(false, "test-export.json");
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dialog state", () => {
|
||||||
|
it("toggles export method dialog", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showExportMethod).toBe(false);
|
||||||
|
act(() => result.current.setShowExportMethod(true));
|
||||||
|
expect(result.current.showExportMethod).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles import method dialog", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.showImportMethod).toBe(false);
|
||||||
|
act(() => result.current.setShowImportMethod(true));
|
||||||
|
expect(result.current.showImportMethod).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears import error", () => {
|
||||||
|
const { result } = renderHook(() => useEncounterExportImport(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleImportClipboard("bad json");
|
||||||
|
});
|
||||||
|
expect(result.current.importError).toBe("Invalid file format");
|
||||||
|
|
||||||
|
act(() => result.current.setImportError(null));
|
||||||
|
expect(result.current.importError).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,28 +1,37 @@
|
|||||||
// @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 { beforeEach, describe, expect, it, vi } from "vitest";
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.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";
|
||||||
|
|
||||||
vi.mock("../../persistence/encounter-storage.js", () => ({
|
beforeAll(() => {
|
||||||
loadEncounter: vi.fn().mockReturnValue(null),
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
saveEncounter: vi.fn(),
|
writable: true,
|
||||||
}));
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
const { loadEncounter: mockLoad, saveEncounter: mockSave } =
|
media: query,
|
||||||
await vi.importMock<typeof import("../../persistence/encounter-storage.js")>(
|
onchange: null,
|
||||||
"../../persistence/encounter-storage.js",
|
addListener: vi.fn(),
|
||||||
);
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
describe("useEncounter", () => {
|
removeEventListener: vi.fn(),
|
||||||
beforeEach(() => {
|
dispatchEvent: vi.fn(),
|
||||||
vi.clearAllMocks();
|
})),
|
||||||
mockLoad.mockReturnValue(null);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <AllProviders>{children}</AllProviders>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useEncounter", () => {
|
||||||
it("initializes with empty encounter when persistence returns null", () => {
|
it("initializes with empty encounter when persistence returns null", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
expect(result.current.encounter.combatants).toEqual([]);
|
expect(result.current.encounter.combatants).toEqual([]);
|
||||||
expect(result.current.encounter.activeIndex).toBe(0);
|
expect(result.current.encounter.activeIndex).toBe(0);
|
||||||
@@ -32,13 +41,33 @@ describe("useEncounter", () => {
|
|||||||
|
|
||||||
it("initializes from stored encounter", () => {
|
it("initializes from stored encounter", () => {
|
||||||
const stored = {
|
const stored = {
|
||||||
combatants: [{ id: combatantId("c-1"), name: "Goblin" }],
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Goblin",
|
||||||
|
initiative: undefined,
|
||||||
|
maxHp: undefined,
|
||||||
|
currentHp: undefined,
|
||||||
|
tempHp: undefined,
|
||||||
|
ac: undefined,
|
||||||
|
conditions: [],
|
||||||
|
concentrating: false,
|
||||||
|
creatureId: undefined,
|
||||||
|
playerCharacterId: undefined,
|
||||||
|
color: undefined,
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
activeIndex: 0,
|
activeIndex: 0,
|
||||||
roundNumber: 2,
|
roundNumber: 2,
|
||||||
};
|
};
|
||||||
mockLoad.mockReturnValue(stored);
|
const adapters = createTestAdapters({ encounter: stored });
|
||||||
|
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.encounter.combatants).toHaveLength(1);
|
expect(result.current.encounter.combatants).toHaveLength(1);
|
||||||
expect(result.current.encounter.roundNumber).toBe(2);
|
expect(result.current.encounter.roundNumber).toBe(2);
|
||||||
@@ -46,7 +75,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addCombatant adds a combatant with incremental IDs and persists", () => {
|
it("addCombatant adds a combatant with incremental IDs and persists", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() => result.current.addCombatant("Goblin"));
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
act(() => result.current.addCombatant("Orc"));
|
act(() => result.current.addCombatant("Orc"));
|
||||||
@@ -55,11 +84,10 @@ describe("useEncounter", () => {
|
|||||||
expect(result.current.encounter.combatants[0].name).toBe("Goblin");
|
expect(result.current.encounter.combatants[0].name).toBe("Goblin");
|
||||||
expect(result.current.encounter.combatants[1].name).toBe("Orc");
|
expect(result.current.encounter.combatants[1].name).toBe("Orc");
|
||||||
expect(result.current.isEmpty).toBe(false);
|
expect(result.current.isEmpty).toBe(false);
|
||||||
expect(mockSave).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removeCombatant removes a combatant and persists", () => {
|
it("removeCombatant removes a combatant and persists", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() => result.current.addCombatant("Goblin"));
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
const id = result.current.encounter.combatants[0].id;
|
const id = result.current.encounter.combatants[0].id;
|
||||||
@@ -71,7 +99,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("advanceTurn and retreatTurn update encounter state", () => {
|
it("advanceTurn and retreatTurn update encounter state", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() => result.current.addCombatant("Goblin"));
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
act(() => result.current.addCombatant("Orc"));
|
act(() => result.current.addCombatant("Orc"));
|
||||||
@@ -86,7 +114,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("clearEncounter resets to empty and resets ID counter", () => {
|
it("clearEncounter resets to empty and resets ID counter", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() => result.current.addCombatant("Goblin"));
|
act(() => result.current.addCombatant("Goblin"));
|
||||||
act(() => result.current.clearEncounter());
|
act(() => result.current.clearEncounter());
|
||||||
@@ -100,7 +128,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addCombatant with opts applies initiative, ac, maxHp", () => {
|
it("addCombatant with opts applies initiative, ac, maxHp", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
act(() =>
|
act(() =>
|
||||||
result.current.addCombatant("Goblin", {
|
result.current.addCombatant("Goblin", {
|
||||||
@@ -118,16 +146,18 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("derived flags: hasCreatureCombatants and canRollAllInitiative", () => {
|
it("derived flags: hasCreatureCombatants and canRollAllInitiative", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
// No creatures yet
|
// No creatures yet
|
||||||
expect(result.current.hasCreatureCombatants).toBe(false);
|
expect(result.current.hasCreatureCombatants).toBe(false);
|
||||||
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,
|
||||||
@@ -146,11 +176,13 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
|
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
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,
|
||||||
@@ -173,11 +205,13 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addFromBestiary auto-numbers duplicate names", () => {
|
it("addFromBestiary auto-numbers duplicate names", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
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,
|
||||||
@@ -200,7 +234,7 @@ describe("useEncounter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("addFromPlayerCharacter adds combatant with HP, AC, color, icon", () => {
|
it("addFromPlayerCharacter adds combatant with HP, AC, color, icon", () => {
|
||||||
const { result } = renderHook(() => useEncounter());
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
|
||||||
const pc: PlayerCharacter = {
|
const pc: PlayerCharacter = {
|
||||||
id: playerCharacterId("pc-1"),
|
id: playerCharacterId("pc-1"),
|
||||||
@@ -1,25 +1,33 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { playerCharacterId } from "@initiative/domain";
|
import { playerCharacterId } from "@initiative/domain";
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import type { ReactNode } from "react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { usePlayerCharacters } from "../use-player-characters.js";
|
import { usePlayerCharacters } from "../use-player-characters.js";
|
||||||
|
|
||||||
vi.mock("../../persistence/player-character-storage.js", () => ({
|
beforeAll(() => {
|
||||||
loadPlayerCharacters: vi.fn().mockReturnValue([]),
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
savePlayerCharacters: vi.fn(),
|
writable: true,
|
||||||
}));
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
const { loadPlayerCharacters: mockLoad, savePlayerCharacters: mockSave } =
|
media: query,
|
||||||
await vi.importMock<
|
onchange: null,
|
||||||
typeof import("../../persistence/player-character-storage.js")
|
addListener: vi.fn(),
|
||||||
>("../../persistence/player-character-storage.js");
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
describe("usePlayerCharacters", () => {
|
removeEventListener: vi.fn(),
|
||||||
beforeEach(() => {
|
dispatchEvent: vi.fn(),
|
||||||
vi.clearAllMocks();
|
})),
|
||||||
mockLoad.mockReturnValue([]);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <AllProviders>{children}</AllProviders>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("usePlayerCharacters", () => {
|
||||||
it("initializes with characters from persistence", () => {
|
it("initializes with characters from persistence", () => {
|
||||||
const stored = [
|
const stored = [
|
||||||
{
|
{
|
||||||
@@ -31,15 +39,19 @@ describe("usePlayerCharacters", () => {
|
|||||||
icon: undefined,
|
icon: undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
mockLoad.mockReturnValue(stored);
|
const adapters = createTestAdapters({ playerCharacters: stored });
|
||||||
|
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.characters).toEqual(stored);
|
expect(result.current.characters).toEqual(stored);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("createCharacter adds a character and persists", () => {
|
it("createCharacter adds a character and persists", () => {
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.createCharacter(
|
result.current.createCharacter(
|
||||||
@@ -56,11 +68,10 @@ describe("usePlayerCharacters", () => {
|
|||||||
expect(result.current.characters[0].name).toBe("Vex");
|
expect(result.current.characters[0].name).toBe("Vex");
|
||||||
expect(result.current.characters[0].ac).toBe(15);
|
expect(result.current.characters[0].ac).toBe(15);
|
||||||
expect(result.current.characters[0].maxHp).toBe(28);
|
expect(result.current.characters[0].maxHp).toBe(28);
|
||||||
expect(mockSave).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("createCharacter returns domain error for empty name", () => {
|
it("createCharacter returns domain error for empty name", () => {
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||||
|
|
||||||
let error: unknown;
|
let error: unknown;
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -79,7 +90,7 @@ describe("usePlayerCharacters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("editCharacter updates character and persists", () => {
|
it("editCharacter updates character and persists", () => {
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.createCharacter(
|
result.current.createCharacter(
|
||||||
@@ -99,11 +110,10 @@ describe("usePlayerCharacters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.characters[0].name).toBe("Vex'ahlia");
|
expect(result.current.characters[0].name).toBe("Vex'ahlia");
|
||||||
expect(mockSave).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deleteCharacter removes character and persists", () => {
|
it("deleteCharacter removes character and persists", () => {
|
||||||
const { result } = renderHook(() => usePlayerCharacters());
|
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.createCharacter(
|
result.current.createCharacter(
|
||||||
@@ -123,6 +133,5 @@ describe("usePlayerCharacters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.characters).toHaveLength(0);
|
expect(result.current.characters).toHaveLength(0);
|
||||||
expect(mockSave).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
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 * as bestiaryCache from "../adapters/bestiary-cache.js";
|
|
||||||
import {
|
import {
|
||||||
getSourceDisplayName,
|
normalizePf2eBestiary,
|
||||||
loadBestiaryIndex,
|
setPf2eSourceDisplayNames,
|
||||||
} from "../adapters/bestiary-index-adapter.js";
|
} from "../adapters/pf2e-bestiary-adapter.js";
|
||||||
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
|
|
||||||
export interface SearchResult extends BestiaryIndexEntry {
|
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<void>;
|
||||||
@@ -32,49 +40,75 @@ interface BestiaryHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBestiary(): BestiaryHook {
|
export function useBestiary(): BestiaryHook {
|
||||||
|
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 = loadBestiaryIndex();
|
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();
|
||||||
|
setPf2eSourceDisplayNames(pf2eIndex.sources as Record<string, string>);
|
||||||
|
|
||||||
|
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, pf2eBestiaryIndex]);
|
||||||
|
|
||||||
const search = useCallback((query: string): SearchResult[] => {
|
const search = useCallback(
|
||||||
|
(query: string): SearchResult[] => {
|
||||||
if (query.length < 2) return [];
|
if (query.length < 2) return [];
|
||||||
const lower = query.toLowerCase();
|
const lower = query.toLowerCase();
|
||||||
const index = loadBestiaryIndex();
|
|
||||||
|
if (edition === "pf2e") {
|
||||||
|
const index = pf2eBestiaryIndex.loadIndex();
|
||||||
return index.creatures
|
return index.creatures
|
||||||
.filter((c) => c.name.toLowerCase().includes(lower))
|
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
sourceDisplayName: getSourceDisplayName(c.source),
|
system: "pf2e" as const,
|
||||||
|
sourceDisplayName: pf2eBestiaryIndex.getSourceDisplayName(c.source),
|
||||||
}));
|
}));
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
|
const index = bestiaryIndex.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: "dnd" as const,
|
||||||
|
sourceDisplayName: bestiaryIndex.getSourceDisplayName(c.source),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[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, system],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchAndCacheSource = useCallback(
|
const fetchAndCacheSource = useCallback(
|
||||||
@@ -86,9 +120,20 @@ export function useBestiary(): BestiaryHook {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
const creatures = normalizeBestiary(json);
|
const creatures =
|
||||||
const displayName = getSourceDisplayName(sourceCode);
|
edition === "pf2e"
|
||||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
? normalizePf2eBestiary(json)
|
||||||
|
: normalizeBestiary(json);
|
||||||
|
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) {
|
||||||
@@ -97,15 +142,27 @@ export function useBestiary(): BestiaryHook {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[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 = getSourceDisplayName(sourceCode);
|
? normalizePf2eBestiary(jsonData as { creature: unknown[] })
|
||||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
: 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,13 +171,13 @@ export function useBestiary(): BestiaryHook {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshCache = useCallback(async (): Promise<void> => {
|
const refreshCache = useCallback(async (): Promise<void> => {
|
||||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||||
setCreatureMap(map);
|
setCreatureMap(map);
|
||||||
}, []);
|
}, [bestiaryCache]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
search,
|
search,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import {
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
getAllSourceCodes,
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
getDefaultFetchUrl,
|
|
||||||
} from "../adapters/bestiary-index-adapter.js";
|
|
||||||
|
|
||||||
const BATCH_SIZE = 6;
|
const BATCH_SIZE = 6;
|
||||||
|
|
||||||
@@ -32,6 +30,9 @@ interface BulkImportHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBulkImport(): BulkImportHook {
|
export function useBulkImport(): BulkImportHook {
|
||||||
|
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 });
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||||
refreshCache: () => Promise<void>,
|
refreshCache: () => Promise<void>,
|
||||||
) => {
|
) => {
|
||||||
const allCodes = 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 };
|
||||||
@@ -83,7 +84,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
chain.then(() =>
|
chain.then(() =>
|
||||||
Promise.allSettled(
|
Promise.allSettled(
|
||||||
batch.map(async ({ code }) => {
|
batch.map(async ({ code }) => {
|
||||||
const url = 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++;
|
||||||
@@ -117,7 +118,7 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
[],
|
[indexPort],
|
||||||
);
|
);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
|
|||||||
171
apps/web/src/hooks/use-difficulty-breakdown.ts
Normal file
171
apps/web/src/hooks/use-difficulty-breakdown.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import type {
|
||||||
|
Combatant,
|
||||||
|
CreatureId,
|
||||||
|
DifficultyThreshold,
|
||||||
|
DifficultyTier,
|
||||||
|
PlayerCharacter,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { calculateEncounterDifficulty, crToXp } from "@initiative/domain";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-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 {
|
||||||
|
readonly combatant: Combatant;
|
||||||
|
readonly cr: string | null;
|
||||||
|
readonly xp: number | null;
|
||||||
|
readonly source: string | null;
|
||||||
|
readonly editable: boolean;
|
||||||
|
readonly side: "party" | "enemy";
|
||||||
|
readonly level: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DifficultyBreakdown {
|
||||||
|
readonly tier: DifficultyTier;
|
||||||
|
readonly totalMonsterXp: number;
|
||||||
|
readonly thresholds: readonly DifficultyThreshold[];
|
||||||
|
readonly encounterMultiplier: number | undefined;
|
||||||
|
readonly adjustedXp: number | undefined;
|
||||||
|
readonly partySizeAdjusted: boolean | undefined;
|
||||||
|
readonly pcCount: number;
|
||||||
|
readonly partyCombatants: readonly BreakdownCombatant[];
|
||||||
|
readonly enemyCombatants: readonly BreakdownCombatant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||||
|
const { encounter } = useEncounterContext();
|
||||||
|
const { characters } = usePlayerCharactersContext();
|
||||||
|
const { getCreature } = useBestiaryContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const { partyCombatants, enemyCombatants, descriptors, pcCount } =
|
||||||
|
classifyCombatants(encounter.combatants, characters, getCreature);
|
||||||
|
|
||||||
|
const hasPartyLevel = descriptors.some(
|
||||||
|
(d) => d.side === "party" && d.level !== undefined,
|
||||||
|
);
|
||||||
|
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||||
|
|
||||||
|
if (!hasPartyLevel || !hasCr) return null;
|
||||||
|
|
||||||
|
const result = calculateEncounterDifficulty(descriptors, edition);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
pcCount,
|
||||||
|
partyCombatants,
|
||||||
|
enemyCombatants,
|
||||||
|
};
|
||||||
|
}, [encounter.combatants, characters, getCreature, edition]);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatureInfo = {
|
||||||
|
cr?: string;
|
||||||
|
source: string;
|
||||||
|
sourceDisplayName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildBreakdownEntry(
|
||||||
|
c: Combatant,
|
||||||
|
side: "party" | "enemy",
|
||||||
|
level: number | undefined,
|
||||||
|
creature: CreatureInfo | undefined,
|
||||||
|
): BreakdownCombatant {
|
||||||
|
if (c.playerCharacterId) {
|
||||||
|
return {
|
||||||
|
combatant: c,
|
||||||
|
cr: null,
|
||||||
|
xp: null,
|
||||||
|
source: null,
|
||||||
|
editable: false,
|
||||||
|
side,
|
||||||
|
level,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (creature) {
|
||||||
|
const cr = creature.cr ?? null;
|
||||||
|
return {
|
||||||
|
combatant: c,
|
||||||
|
cr,
|
||||||
|
xp: cr ? crToXp(cr) : null,
|
||||||
|
source: creature.sourceDisplayName ?? creature.source,
|
||||||
|
editable: false,
|
||||||
|
side,
|
||||||
|
level: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (c.cr) {
|
||||||
|
return {
|
||||||
|
combatant: c,
|
||||||
|
cr: c.cr,
|
||||||
|
xp: crToXp(c.cr),
|
||||||
|
source: null,
|
||||||
|
editable: true,
|
||||||
|
side,
|
||||||
|
level: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
combatant: c,
|
||||||
|
cr: null,
|
||||||
|
xp: null,
|
||||||
|
source: null,
|
||||||
|
editable: !c.creatureId,
|
||||||
|
side,
|
||||||
|
level: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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[],
|
||||||
|
characters: readonly PlayerCharacter[],
|
||||||
|
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||||
|
) {
|
||||||
|
const partyCombatants: BreakdownCombatant[] = [];
|
||||||
|
const enemyCombatants: BreakdownCombatant[] = [];
|
||||||
|
const descriptors: {
|
||||||
|
level?: number;
|
||||||
|
cr?: string;
|
||||||
|
side: "party" | "enemy";
|
||||||
|
}[] = [];
|
||||||
|
let pcCount = 0;
|
||||||
|
|
||||||
|
for (const c of combatants) {
|
||||||
|
const side = resolveSide(c);
|
||||||
|
const level = resolveLevel(c, characters);
|
||||||
|
if (level !== undefined) pcCount++;
|
||||||
|
|
||||||
|
const { cr, creature } = resolveCr(c, getCreature);
|
||||||
|
|
||||||
|
if (level !== undefined || cr != null) {
|
||||||
|
descriptors.push({ level, cr: cr ?? undefined, side });
|
||||||
|
}
|
||||||
|
|
||||||
|
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,46 +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" {
|
||||||
|
if (c.side) return c.side;
|
||||||
|
return c.playerCharacterId ? "party" : "enemy";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDescriptors(
|
||||||
combatants: readonly Combatant[],
|
combatants: readonly Combatant[],
|
||||||
characters: readonly PlayerCharacter[],
|
characters: readonly PlayerCharacter[],
|
||||||
): number[] {
|
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||||
const levels: number[] = [];
|
): CombatantDescriptor[] {
|
||||||
|
const descriptors: CombatantDescriptor[] = [];
|
||||||
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 = c.playerCharacterId
|
||||||
if (pc?.level !== undefined) levels.push(pc.level);
|
? characters.find((p) => p.id === c.playerCharacterId)?.level
|
||||||
}
|
: undefined;
|
||||||
return levels;
|
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||||
}
|
const creatureCr =
|
||||||
|
creature && !("system" in creature) ? creature.cr : undefined;
|
||||||
|
const cr = creatureCr ?? c.cr ?? undefined;
|
||||||
|
|
||||||
function deriveMonsterCrs(
|
if (level !== undefined || cr !== undefined) {
|
||||||
combatants: readonly Combatant[],
|
descriptors.push({ level, cr, side });
|
||||||
getCreature: (id: CreatureId) => { cr: string } | undefined,
|
|
||||||
): string[] {
|
|
||||||
const crs: string[] = [];
|
|
||||||
for (const c of combatants) {
|
|
||||||
if (!c.creatureId) continue;
|
|
||||||
const creature = getCreature(c.creatureId);
|
|
||||||
if (creature) crs.push(creature.cr);
|
|
||||||
}
|
}
|
||||||
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]);
|
||||||
}
|
}
|
||||||
|
|||||||
139
apps/web/src/hooks/use-encounter-export-import.ts
Normal file
139
apps/web/src/hooks/use-encounter-export-import.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import type { ExportBundle } from "@initiative/domain";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
|
import {
|
||||||
|
assembleExportBundle,
|
||||||
|
bundleToJson,
|
||||||
|
readImportFile,
|
||||||
|
triggerDownload,
|
||||||
|
validateImportBundle,
|
||||||
|
} from "../persistence/export-import.js";
|
||||||
|
|
||||||
|
export function useEncounterExportImport() {
|
||||||
|
const {
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
isEmpty: encounterIsEmpty,
|
||||||
|
setEncounter,
|
||||||
|
setUndoRedoState,
|
||||||
|
} = useEncounterContext();
|
||||||
|
const { characters: playerCharacters, replacePlayerCharacters } =
|
||||||
|
usePlayerCharactersContext();
|
||||||
|
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
const [showExportMethod, setShowExportMethod] = useState(false);
|
||||||
|
const [showImportMethod, setShowImportMethod] = useState(false);
|
||||||
|
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||||
|
const pendingBundleRef = useRef<ExportBundle | null>(null);
|
||||||
|
const importFileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleExportDownload = useCallback(
|
||||||
|
(includeHistory: boolean, filename: string) => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
includeHistory,
|
||||||
|
);
|
||||||
|
triggerDownload(bundle, filename);
|
||||||
|
},
|
||||||
|
[encounter, undoRedoState, playerCharacters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExportClipboard = useCallback(
|
||||||
|
(includeHistory: boolean) => {
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounter,
|
||||||
|
undoRedoState,
|
||||||
|
playerCharacters,
|
||||||
|
includeHistory,
|
||||||
|
);
|
||||||
|
void navigator.clipboard.writeText(bundleToJson(bundle));
|
||||||
|
},
|
||||||
|
[encounter, undoRedoState, playerCharacters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyImport = useCallback(
|
||||||
|
(bundle: ExportBundle) => {
|
||||||
|
setEncounter(bundle.encounter);
|
||||||
|
setUndoRedoState({
|
||||||
|
undoStack: bundle.undoStack,
|
||||||
|
redoStack: bundle.redoStack,
|
||||||
|
});
|
||||||
|
replacePlayerCharacters([...bundle.playerCharacters]);
|
||||||
|
},
|
||||||
|
[setEncounter, setUndoRedoState, replacePlayerCharacters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleValidatedBundle = useCallback(
|
||||||
|
(result: ExportBundle | string) => {
|
||||||
|
if (typeof result === "string") {
|
||||||
|
setImportError(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (encounterIsEmpty) {
|
||||||
|
applyImport(result);
|
||||||
|
} else {
|
||||||
|
pendingBundleRef.current = result;
|
||||||
|
setShowImportConfirm(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[encounterIsEmpty, applyImport],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImportFile = useCallback(
|
||||||
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (importFileRef.current) importFileRef.current.value = "";
|
||||||
|
|
||||||
|
setImportError(null);
|
||||||
|
handleValidatedBundle(await readImportFile(file));
|
||||||
|
},
|
||||||
|
[handleValidatedBundle],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImportClipboard = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
setImportError(null);
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(text);
|
||||||
|
handleValidatedBundle(validateImportBundle(parsed));
|
||||||
|
} catch {
|
||||||
|
setImportError("Invalid file format");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleValidatedBundle],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleImportConfirm = useCallback(() => {
|
||||||
|
if (pendingBundleRef.current) {
|
||||||
|
applyImport(pendingBundleRef.current);
|
||||||
|
pendingBundleRef.current = null;
|
||||||
|
}
|
||||||
|
setShowImportConfirm(false);
|
||||||
|
}, [applyImport]);
|
||||||
|
|
||||||
|
const handleImportCancel = useCallback(() => {
|
||||||
|
pendingBundleRef.current = null;
|
||||||
|
setShowImportConfirm(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
importError,
|
||||||
|
showExportMethod,
|
||||||
|
showImportMethod,
|
||||||
|
showImportConfirm,
|
||||||
|
importFileRef,
|
||||||
|
setImportError,
|
||||||
|
setShowExportMethod,
|
||||||
|
setShowImportMethod,
|
||||||
|
handleExportDownload,
|
||||||
|
handleExportClipboard,
|
||||||
|
handleImportFile,
|
||||||
|
handleImportClipboard,
|
||||||
|
handleImportConfirm,
|
||||||
|
handleImportCancel,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
@@ -4,20 +4,23 @@ import {
|
|||||||
adjustHpUseCase,
|
adjustHpUseCase,
|
||||||
advanceTurnUseCase,
|
advanceTurnUseCase,
|
||||||
clearEncounterUseCase,
|
clearEncounterUseCase,
|
||||||
|
decrementConditionUseCase,
|
||||||
editCombatantUseCase,
|
editCombatantUseCase,
|
||||||
redoUseCase,
|
redoUseCase,
|
||||||
removeCombatantUseCase,
|
removeCombatantUseCase,
|
||||||
retreatTurnUseCase,
|
retreatTurnUseCase,
|
||||||
setAcUseCase,
|
setAcUseCase,
|
||||||
|
setConditionValueUseCase,
|
||||||
|
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,
|
||||||
@@ -37,14 +40,8 @@ import {
|
|||||||
resolveCreatureName,
|
resolveCreatureName,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||||
import {
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
loadEncounter,
|
import type { SearchResult } from "./use-bestiary.js";
|
||||||
saveEncounter,
|
|
||||||
} from "../persistence/encounter-storage.js";
|
|
||||||
import {
|
|
||||||
loadUndoRedoStacks,
|
|
||||||
saveUndoRedoStacks,
|
|
||||||
} from "../persistence/undo-redo-storage.js";
|
|
||||||
|
|
||||||
// -- Types --
|
// -- Types --
|
||||||
|
|
||||||
@@ -59,19 +56,32 @@ type EncounterAction =
|
|||||||
| { type: "adjust-hp"; id: CombatantId; delta: number }
|
| { type: "adjust-hp"; id: CombatantId; delta: number }
|
||||||
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
||||||
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
||||||
|
| { 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: "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: "add-from-player-character"; pc: PlayerCharacter }
|
| { type: "add-from-player-character"; pc: PlayerCharacter }
|
||||||
@@ -111,11 +121,14 @@ function deriveNextId(encounter: Encounter): number {
|
|||||||
return max;
|
return max;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeState(): EncounterState {
|
function initializeState(
|
||||||
const encounter = loadEncounter() ?? EMPTY_ENCOUNTER;
|
loadEncounterFn: () => Encounter | null,
|
||||||
|
loadUndoRedoFn: () => UndoRedoState,
|
||||||
|
): EncounterState {
|
||||||
|
const encounter = loadEncounterFn() ?? EMPTY_ENCOUNTER;
|
||||||
return {
|
return {
|
||||||
encounter,
|
encounter,
|
||||||
undoRedoState: loadUndoRedoStacks(),
|
undoRedoState: loadUndoRedoFn(),
|
||||||
events: [],
|
events: [],
|
||||||
nextId: deriveNextId(encounter),
|
nextId: deriveNextId(encounter),
|
||||||
lastCreatureId: null,
|
lastCreatureId: null,
|
||||||
@@ -156,7 +169,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;
|
||||||
@@ -215,7 +228,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);
|
||||||
@@ -322,7 +335,11 @@ function dispatchEncounterAction(
|
|||||||
| { type: "adjust-hp" }
|
| { type: "adjust-hp" }
|
||||||
| { type: "set-temp-hp" }
|
| { type: "set-temp-hp" }
|
||||||
| { type: "set-ac" }
|
| { type: "set-ac" }
|
||||||
|
| { 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" }
|
||||||
>,
|
>,
|
||||||
): EncounterState {
|
): EncounterState {
|
||||||
@@ -362,9 +379,26 @@ function dispatchEncounterAction(
|
|||||||
case "set-ac":
|
case "set-ac":
|
||||||
result = setAcUseCase(store, action.id, action.value);
|
result = setAcUseCase(store, action.id, action.value);
|
||||||
break;
|
break;
|
||||||
|
case "set-cr":
|
||||||
|
result = setCrUseCase(store, action.id, action.value);
|
||||||
|
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;
|
||||||
@@ -385,7 +419,10 @@ function dispatchEncounterAction(
|
|||||||
// -- Hook --
|
// -- Hook --
|
||||||
|
|
||||||
export function useEncounter() {
|
export function useEncounter() {
|
||||||
const [state, dispatch] = useReducer(encounterReducer, null, initializeState);
|
const { encounterPersistence, undoRedoPersistence } = useAdapters();
|
||||||
|
const [state, dispatch] = useReducer(encounterReducer, null, () =>
|
||||||
|
initializeState(encounterPersistence.load, undoRedoPersistence.load),
|
||||||
|
);
|
||||||
const { encounter, undoRedoState, events } = state;
|
const { encounter, undoRedoState, events } = state;
|
||||||
|
|
||||||
const encounterRef = useRef(encounter);
|
const encounterRef = useRef(encounter);
|
||||||
@@ -394,12 +431,12 @@ export function useEncounter() {
|
|||||||
undoRedoRef.current = undoRedoState;
|
undoRedoRef.current = undoRedoState;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveEncounter(encounter);
|
encounterPersistence.save(encounter);
|
||||||
}, [encounter]);
|
}, [encounter, encounterPersistence]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveUndoRedoStacks(undoRedoState);
|
undoRedoPersistence.save(undoRedoState);
|
||||||
}, [undoRedoState]);
|
}, [undoRedoState, undoRedoPersistence]);
|
||||||
|
|
||||||
// Escape hatches for useInitiativeRolls (needs raw port access)
|
// Escape hatches for useInitiativeRolls (needs raw port access)
|
||||||
const makeStore = useCallback((): EncounterStore => {
|
const makeStore = useCallback((): EncounterStore => {
|
||||||
@@ -496,11 +533,31 @@ export function useEncounter() {
|
|||||||
dispatch({ type: "set-ac", id, value }),
|
dispatch({ type: "set-ac", id, value }),
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
|
setCr: useCallback(
|
||||||
|
(id: CombatantId, value: string | undefined) =>
|
||||||
|
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 }),
|
||||||
[],
|
[],
|
||||||
@@ -509,15 +566,12 @@ export function useEncounter() {
|
|||||||
() => 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,
|
||||||
|
|||||||
@@ -7,14 +7,7 @@ import {
|
|||||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
import { isDomainError, playerCharacterId } from "@initiative/domain";
|
import { isDomainError, playerCharacterId } from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
loadPlayerCharacters,
|
|
||||||
savePlayerCharacters,
|
|
||||||
} from "../persistence/player-character-storage.js";
|
|
||||||
|
|
||||||
function initializeCharacters(): PlayerCharacter[] {
|
|
||||||
return loadPlayerCharacters();
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextPcId = 0;
|
let nextPcId = 0;
|
||||||
|
|
||||||
@@ -32,14 +25,16 @@ interface EditFields {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePlayerCharacters() {
|
export function usePlayerCharacters() {
|
||||||
const [characters, setCharacters] =
|
const { playerCharacterPersistence } = useAdapters();
|
||||||
useState<PlayerCharacter[]>(initializeCharacters);
|
const [characters, setCharacters] = useState<PlayerCharacter[]>(() =>
|
||||||
|
playerCharacterPersistence.load(),
|
||||||
|
);
|
||||||
const charactersRef = useRef(characters);
|
const charactersRef = useRef(characters);
|
||||||
charactersRef.current = characters;
|
charactersRef.current = characters;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
savePlayerCharacters(characters);
|
playerCharacterPersistence.save(characters);
|
||||||
}, [characters]);
|
}, [characters, playerCharacterPersistence]);
|
||||||
|
|
||||||
const makeStore = useCallback((): PlayerCharacterStore => {
|
const makeStore = useCallback((): PlayerCharacterStore => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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,6 +1,8 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App.js";
|
import { App } from "./App.js";
|
||||||
|
import { productionAdapters } from "./adapters/production-adapters.js";
|
||||||
|
import { AdapterProvider } from "./contexts/adapter-context.js";
|
||||||
import {
|
import {
|
||||||
BestiaryProvider,
|
BestiaryProvider,
|
||||||
BulkImportProvider,
|
BulkImportProvider,
|
||||||
@@ -17,6 +19,7 @@ const root = document.getElementById("root");
|
|||||||
if (root) {
|
if (root) {
|
||||||
createRoot(root).render(
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<AdapterProvider adapters={productionAdapters}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<RulesEditionProvider>
|
<RulesEditionProvider>
|
||||||
<EncounterProvider>
|
<EncounterProvider>
|
||||||
@@ -34,6 +37,7 @@ if (root) {
|
|||||||
</EncounterProvider>
|
</EncounterProvider>
|
||||||
</RulesEditionProvider>
|
</RulesEditionProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</AdapterProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,67 @@ describe("loadEncounter", () => {
|
|||||||
expect(loadEncounter()).toBeNull();
|
expect(loadEncounter()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trip preserves combatant cr field", () => {
|
||||||
|
const result = createEncounter(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Custom Thug",
|
||||||
|
cr: "2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error("unreachable");
|
||||||
|
saveEncounter(result);
|
||||||
|
const loaded = loadEncounter();
|
||||||
|
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
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);
|
||||||
|
|||||||
25103
data/bestiary/pf2e-index.json
Normal file
25103
data/bestiary/pf2e-index.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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", () => {
|
||||||
|
|||||||
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ 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";
|
||||||
@@ -21,8 +22,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 { 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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-cr-use-case.ts
Normal file
18
packages/application/src/set-cr-use-case.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
setCr,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
|
export function setCrUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
value: string | undefined,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
return runEncounterAction(store, (encounter) =>
|
||||||
|
setCr(encounter, combatantId, 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) {
|
|
||||||
expect(def.description).toBeTruthy();
|
it("falls back to default description for pf2e when no pf2e text", () => {
|
||||||
expect(def.description5e).toBeTruthy();
|
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, `${def.id} missing description`).toBeTruthy();
|
||||||
|
expect(def.description5e, `${def.id} missing 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");
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
@@ -219,6 +257,50 @@ describe("rehydrateCombatant", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves valid cr field", () => {
|
||||||
|
for (const cr of ["5", "1/4", "0", "30"]) {
|
||||||
|
const result = rehydrateCombatant({ ...minimalCombatant(), cr });
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.cr).toBe(cr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops invalid cr field", () => {
|
||||||
|
for (const cr of ["99", "", 42, null, "abc"]) {
|
||||||
|
const result = rehydrateCombatant({ ...minimalCombatant(), cr });
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.cr).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combatant without cr rehydrates as before", () => {
|
||||||
|
const result = rehydrateCombatant(minimalCombatant());
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
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("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({
|
||||||
|
|||||||
146
packages/domain/src/__tests__/set-cr.test.ts
Normal file
146
packages/domain/src/__tests__/set-cr.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { setCr } from "../set-cr.js";
|
||||||
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
|
function makeCombatant(name: string, cr?: string): Combatant {
|
||||||
|
return cr === undefined
|
||||||
|
? { id: combatantId(name), name }
|
||||||
|
: { id: combatantId(name), name, cr };
|
||||||
|
}
|
||||||
|
|
||||||
|
function enc(
|
||||||
|
combatants: Combatant[],
|
||||||
|
activeIndex = 0,
|
||||||
|
roundNumber = 1,
|
||||||
|
): Encounter {
|
||||||
|
return { combatants, activeIndex, roundNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
function successResult(
|
||||||
|
encounter: Encounter,
|
||||||
|
id: string,
|
||||||
|
value: string | undefined,
|
||||||
|
) {
|
||||||
|
const result = setCr(encounter, combatantId(id), value);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("setCr", () => {
|
||||||
|
it("sets CR to a valid integer value", () => {
|
||||||
|
const e = enc([makeCombatant("A"), makeCombatant("B")]);
|
||||||
|
const { encounter, events } = successResult(e, "A", "5");
|
||||||
|
|
||||||
|
expect(encounter.combatants[0].cr).toBe("5");
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "CrSet",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousCr: undefined,
|
||||||
|
newCr: "5",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets CR to 0", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const { encounter } = successResult(e, "A", "0");
|
||||||
|
|
||||||
|
expect(encounter.combatants[0].cr).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets CR to fractional values", () => {
|
||||||
|
for (const cr of ["1/8", "1/4", "1/2"]) {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const { encounter } = successResult(e, "A", cr);
|
||||||
|
expect(encounter.combatants[0].cr).toBe(cr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets CR to 30", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const { encounter } = successResult(e, "A", "30");
|
||||||
|
|
||||||
|
expect(encounter.combatants[0].cr).toBe("30");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears CR with undefined", () => {
|
||||||
|
const e = enc([makeCombatant("A", "5")]);
|
||||||
|
const { encounter, events } = successResult(e, "A", undefined);
|
||||||
|
|
||||||
|
expect(encounter.combatants[0].cr).toBeUndefined();
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
previousCr: "5",
|
||||||
|
newCr: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for nonexistent combatant", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = setCr(e, combatantId("nonexistent"), "1");
|
||||||
|
|
||||||
|
expectDomainError(result, "combatant-not-found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for invalid CR string", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = setCr(e, combatantId("A"), "99");
|
||||||
|
|
||||||
|
expectDomainError(result, "invalid-cr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for empty string CR", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = setCr(e, combatantId("A"), "");
|
||||||
|
|
||||||
|
expectDomainError(result, "invalid-cr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves other fields when setting CR", () => {
|
||||||
|
const combatant: Combatant = {
|
||||||
|
id: combatantId("A"),
|
||||||
|
name: "Aria",
|
||||||
|
initiative: 15,
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 18,
|
||||||
|
ac: 14,
|
||||||
|
};
|
||||||
|
const e = enc([combatant]);
|
||||||
|
const { encounter } = successResult(e, "A", "2");
|
||||||
|
|
||||||
|
const updated = encounter.combatants[0];
|
||||||
|
expect(updated.cr).toBe("2");
|
||||||
|
expect(updated.name).toBe("Aria");
|
||||||
|
expect(updated.initiative).toBe(15);
|
||||||
|
expect(updated.maxHp).toBe(20);
|
||||||
|
expect(updated.currentHp).toBe(18);
|
||||||
|
expect(updated.ac).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reorder combatants", () => {
|
||||||
|
const e = enc([makeCombatant("A"), makeCombatant("B")]);
|
||||||
|
const { encounter } = successResult(e, "B", "1");
|
||||||
|
|
||||||
|
expect(encounter.combatants[0].id).toBe(combatantId("A"));
|
||||||
|
expect(encounter.combatants[1].id).toBe(combatantId("B"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves activeIndex and roundNumber", () => {
|
||||||
|
const e = enc([makeCombatant("A"), makeCombatant("B")], 1, 5);
|
||||||
|
const { encounter } = successResult(e, "A", "1/4");
|
||||||
|
|
||||||
|
expect(encounter.activeIndex).toBe(1);
|
||||||
|
expect(encounter.roundNumber).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate input encounter", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const original = JSON.parse(JSON.stringify(e));
|
||||||
|
setCr(e, combatantId("A"), "10");
|
||||||
|
expect(e).toEqual(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
115
packages/domain/src/__tests__/set-side.test.ts
Normal file
115
packages/domain/src/__tests__/set-side.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { setSide } from "../set-side.js";
|
||||||
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
|
function makeCombatant(name: string, side?: "party" | "enemy"): Combatant {
|
||||||
|
return side === undefined
|
||||||
|
? { id: combatantId(name), name }
|
||||||
|
: { id: combatantId(name), name, side };
|
||||||
|
}
|
||||||
|
|
||||||
|
function enc(
|
||||||
|
combatants: Combatant[],
|
||||||
|
activeIndex = 0,
|
||||||
|
roundNumber = 1,
|
||||||
|
): Encounter {
|
||||||
|
return { combatants, activeIndex, roundNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
function successResult(
|
||||||
|
encounter: Encounter,
|
||||||
|
id: string,
|
||||||
|
value: "party" | "enemy",
|
||||||
|
) {
|
||||||
|
const result = setSide(encounter, combatantId(id), value);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("setSide", () => {
|
||||||
|
it("sets side to party", () => {
|
||||||
|
const e = enc([makeCombatant("A"), makeCombatant("B")]);
|
||||||
|
const { encounter, events } = successResult(e, "A", "party");
|
||||||
|
|
||||||
|
expect(encounter.combatants[0].side).toBe("party");
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "SideSet",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousSide: undefined,
|
||||||
|
newSide: "party",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets side to enemy", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const { encounter } = successResult(e, "A", "enemy");
|
||||||
|
|
||||||
|
expect(encounter.combatants[0].side).toBe("enemy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records previous side in event", () => {
|
||||||
|
const e = enc([makeCombatant("A", "party")]);
|
||||||
|
const { events } = successResult(e, "A", "enemy");
|
||||||
|
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
previousSide: "party",
|
||||||
|
newSide: "enemy",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for nonexistent combatant", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = setSide(e, combatantId("nonexistent"), "party");
|
||||||
|
|
||||||
|
expectDomainError(result, "combatant-not-found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves other fields when setting side", () => {
|
||||||
|
const combatant: Combatant = {
|
||||||
|
id: combatantId("A"),
|
||||||
|
name: "Aria",
|
||||||
|
initiative: 15,
|
||||||
|
maxHp: 20,
|
||||||
|
currentHp: 18,
|
||||||
|
ac: 14,
|
||||||
|
cr: "2",
|
||||||
|
};
|
||||||
|
const e = enc([combatant]);
|
||||||
|
const { encounter } = successResult(e, "A", "party");
|
||||||
|
|
||||||
|
const updated = encounter.combatants[0];
|
||||||
|
expect(updated.side).toBe("party");
|
||||||
|
expect(updated.name).toBe("Aria");
|
||||||
|
expect(updated.initiative).toBe(15);
|
||||||
|
expect(updated.cr).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reorder combatants", () => {
|
||||||
|
const e = enc([makeCombatant("A"), makeCombatant("B")]);
|
||||||
|
const { encounter } = successResult(e, "B", "party");
|
||||||
|
|
||||||
|
expect(encounter.combatants[0].id).toBe(combatantId("A"));
|
||||||
|
expect(encounter.combatants[1].id).toBe(combatantId("B"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves activeIndex and roundNumber", () => {
|
||||||
|
const e = enc([makeCombatant("A"), makeCombatant("B")], 1, 5);
|
||||||
|
const { encounter } = successResult(e, "A", "party");
|
||||||
|
|
||||||
|
expect(encounter.activeIndex).toBe(1);
|
||||||
|
expect(encounter.roundNumber).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate input encounter", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const original = JSON.parse(JSON.stringify(e));
|
||||||
|
setSide(e, combatantId("A"), "party");
|
||||||
|
expect(e).toEqual(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ConditionId } from "../conditions.js";
|
import type { ConditionEntry, ConditionId } from "../conditions.js";
|
||||||
import { CONDITION_DEFINITIONS } from "../conditions.js";
|
import { CONDITION_DEFINITIONS } from "../conditions.js";
|
||||||
import { toggleCondition } from "../toggle-condition.js";
|
import {
|
||||||
|
decrementCondition,
|
||||||
|
setConditionValue,
|
||||||
|
toggleCondition,
|
||||||
|
} from "../toggle-condition.js";
|
||||||
import type { Combatant, Encounter } from "../types.js";
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
import { combatantId, isDomainError } from "../types.js";
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
import { expectDomainError } from "./test-helpers.js";
|
import { expectDomainError } from "./test-helpers.js";
|
||||||
|
|
||||||
function makeCombatant(
|
function makeCombatant(
|
||||||
name: string,
|
name: string,
|
||||||
conditions?: readonly ConditionId[],
|
conditions?: readonly ConditionEntry[],
|
||||||
): Combatant {
|
): Combatant {
|
||||||
return conditions
|
return conditions
|
||||||
? { id: combatantId(name), name, conditions }
|
? { id: combatantId(name), name, conditions }
|
||||||
@@ -32,7 +36,7 @@ describe("toggleCondition", () => {
|
|||||||
const e = enc([makeCombatant("A")]);
|
const e = enc([makeCombatant("A")]);
|
||||||
const { encounter, events } = success(e, "A", "blinded");
|
const { encounter, events } = success(e, "A", "blinded");
|
||||||
|
|
||||||
expect(encounter.combatants[0].conditions).toEqual(["blinded"]);
|
expect(encounter.combatants[0].conditions).toEqual([{ id: "blinded" }]);
|
||||||
expect(events).toEqual([
|
expect(events).toEqual([
|
||||||
{
|
{
|
||||||
type: "ConditionAdded",
|
type: "ConditionAdded",
|
||||||
@@ -43,7 +47,7 @@ describe("toggleCondition", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("removes a condition when already present", () => {
|
it("removes a condition when already present", () => {
|
||||||
const e = enc([makeCombatant("A", ["blinded"])]);
|
const e = enc([makeCombatant("A", [{ id: "blinded" }])]);
|
||||||
const { encounter, events } = success(e, "A", "blinded");
|
const { encounter, events } = success(e, "A", "blinded");
|
||||||
|
|
||||||
expect(encounter.combatants[0].conditions).toBeUndefined();
|
expect(encounter.combatants[0].conditions).toBeUndefined();
|
||||||
@@ -57,14 +61,17 @@ describe("toggleCondition", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("maintains definition order when adding conditions", () => {
|
it("maintains definition order when adding conditions", () => {
|
||||||
const e = enc([makeCombatant("A", ["poisoned"])]);
|
const e = enc([makeCombatant("A", [{ id: "poisoned" }])]);
|
||||||
const { encounter } = success(e, "A", "blinded");
|
const { encounter } = success(e, "A", "blinded");
|
||||||
|
|
||||||
expect(encounter.combatants[0].conditions).toEqual(["blinded", "poisoned"]);
|
expect(encounter.combatants[0].conditions).toEqual([
|
||||||
|
{ id: "blinded" },
|
||||||
|
{ id: "poisoned" },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prevents duplicate conditions", () => {
|
it("prevents duplicate conditions", () => {
|
||||||
const e = enc([makeCombatant("A", ["blinded"])]);
|
const e = enc([makeCombatant("A", [{ id: "blinded" }])]);
|
||||||
// Toggling blinded again removes it, not duplicates
|
// Toggling blinded again removes it, not duplicates
|
||||||
const { encounter } = success(e, "A", "blinded");
|
const { encounter } = success(e, "A", "blinded");
|
||||||
expect(encounter.combatants[0].conditions).toBeUndefined();
|
expect(encounter.combatants[0].conditions).toBeUndefined();
|
||||||
@@ -96,7 +103,7 @@ describe("toggleCondition", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes empty array to undefined on removal", () => {
|
it("normalizes empty array to undefined on removal", () => {
|
||||||
const e = enc([makeCombatant("A", ["charmed"])]);
|
const e = enc([makeCombatant("A", [{ id: "charmed" }])]);
|
||||||
const { encounter } = success(e, "A", "charmed");
|
const { encounter } = success(e, "A", "charmed");
|
||||||
|
|
||||||
expect(encounter.combatants[0].conditions).toBeUndefined();
|
expect(encounter.combatants[0].conditions).toBeUndefined();
|
||||||
@@ -110,6 +117,91 @@ describe("toggleCondition", () => {
|
|||||||
const result = success(e, "A", cond);
|
const result = success(e, "A", cond);
|
||||||
e = result.encounter;
|
e = result.encounter;
|
||||||
}
|
}
|
||||||
expect(e.combatants[0].conditions).toEqual(order);
|
expect(e.combatants[0].conditions).toEqual(order.map((id) => ({ id })));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setConditionValue", () => {
|
||||||
|
it("adds a valued condition at the specified value", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = setConditionValue(e, combatantId("A"), "frightened", 2);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
||||||
|
{ id: "frightened", value: 2 },
|
||||||
|
]);
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{
|
||||||
|
type: "ConditionAdded",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
condition: "frightened",
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the value of an existing condition", () => {
|
||||||
|
const e = enc([makeCombatant("A", [{ id: "frightened", value: 1 }])]);
|
||||||
|
const result = setConditionValue(e, combatantId("A"), "frightened", 3);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
||||||
|
{ id: "frightened", value: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes condition when value is 0", () => {
|
||||||
|
const e = enc([makeCombatant("A", [{ id: "frightened", value: 2 }])]);
|
||||||
|
const result = setConditionValue(e, combatantId("A"), "frightened", 0);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].conditions).toBeUndefined();
|
||||||
|
expect(result.events[0].type).toBe("ConditionRemoved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown condition", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = setConditionValue(
|
||||||
|
e,
|
||||||
|
combatantId("A"),
|
||||||
|
"flying" as ConditionId,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expectDomainError(result, "unknown-condition");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decrementCondition", () => {
|
||||||
|
it("decrements value by 1", () => {
|
||||||
|
const e = enc([makeCombatant("A", [{ id: "frightened", value: 3 }])]);
|
||||||
|
const result = decrementCondition(e, combatantId("A"), "frightened");
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
||||||
|
{ id: "frightened", value: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes condition when value reaches 0", () => {
|
||||||
|
const e = enc([makeCombatant("A", [{ id: "frightened", value: 1 }])]);
|
||||||
|
const result = decrementCondition(e, combatantId("A"), "frightened");
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].conditions).toBeUndefined();
|
||||||
|
expect(result.events[0].type).toBe("ConditionRemoved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes non-valued condition (value undefined treated as 1)", () => {
|
||||||
|
const e = enc([makeCombatant("A", [{ id: "blinded" }])]);
|
||||||
|
const result = decrementCondition(e, combatantId("A"), "blinded");
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].conditions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for inactive condition", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = decrementCondition(e, combatantId("A"), "frightened");
|
||||||
|
expectDomainError(result, "condition-not-active");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,43 +1,74 @@
|
|||||||
export type ConditionId =
|
export type ConditionId =
|
||||||
| "blinded"
|
| "blinded"
|
||||||
| "charmed"
|
| "charmed"
|
||||||
|
| "clumsy"
|
||||||
|
| "concealed"
|
||||||
|
| "confused"
|
||||||
|
| "controlled"
|
||||||
|
| "dazzled"
|
||||||
| "deafened"
|
| "deafened"
|
||||||
|
| "doomed"
|
||||||
|
| "drained"
|
||||||
|
| "dying"
|
||||||
|
| "enfeebled"
|
||||||
| "exhaustion"
|
| "exhaustion"
|
||||||
|
| "fascinated"
|
||||||
|
| "fatigued"
|
||||||
|
| "fleeing"
|
||||||
| "frightened"
|
| "frightened"
|
||||||
|
| "grabbed"
|
||||||
| "grappled"
|
| "grappled"
|
||||||
|
| "hidden"
|
||||||
|
| "immobilized"
|
||||||
| "incapacitated"
|
| "incapacitated"
|
||||||
| "invisible"
|
| "invisible"
|
||||||
|
| "off-guard"
|
||||||
| "paralyzed"
|
| "paralyzed"
|
||||||
| "petrified"
|
| "petrified"
|
||||||
| "poisoned"
|
| "poisoned"
|
||||||
| "prone"
|
| "prone"
|
||||||
|
| "quickened"
|
||||||
| "restrained"
|
| "restrained"
|
||||||
| "sapped"
|
| "sapped"
|
||||||
|
| "sickened"
|
||||||
| "slowed"
|
| "slowed"
|
||||||
|
| "slowed-pf2e"
|
||||||
| "stunned"
|
| "stunned"
|
||||||
| "unconscious";
|
| "stupefied"
|
||||||
|
| "unconscious"
|
||||||
|
| "undetected"
|
||||||
|
| "wounded";
|
||||||
|
|
||||||
export type RulesEdition = "5e" | "5.5e";
|
export interface ConditionEntry {
|
||||||
|
readonly id: ConditionId;
|
||||||
|
readonly value?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
import type { RulesEdition } from "./rules-edition.js";
|
||||||
|
|
||||||
export interface ConditionDefinition {
|
export interface ConditionDefinition {
|
||||||
readonly id: ConditionId;
|
readonly id: ConditionId;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
readonly description5e: string;
|
readonly description5e: string;
|
||||||
|
readonly descriptionPf2e?: string;
|
||||||
readonly iconName: string;
|
readonly iconName: string;
|
||||||
readonly color: string;
|
readonly color: string;
|
||||||
/** When set, the condition only appears in this edition's picker. */
|
/** When set, the condition only appears in these systems' pickers. */
|
||||||
readonly edition?: RulesEdition;
|
readonly systems?: readonly RulesEdition[];
|
||||||
|
readonly valued?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConditionDescription(
|
export function getConditionDescription(
|
||||||
def: ConditionDefinition,
|
def: ConditionDefinition,
|
||||||
edition: RulesEdition,
|
edition: RulesEdition,
|
||||||
): string {
|
): string {
|
||||||
|
if (edition === "pf2e" && def.descriptionPf2e) return def.descriptionPf2e;
|
||||||
return edition === "5e" ? def.description5e : def.description;
|
return edition === "5e" ? def.description5e : def.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||||
|
// ── Shared conditions (D&D + PF2e) ──
|
||||||
{
|
{
|
||||||
id: "blinded",
|
id: "blinded",
|
||||||
label: "Blinded",
|
label: "Blinded",
|
||||||
@@ -45,6 +76,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
||||||
description5e:
|
description5e:
|
||||||
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Can't see. All terrain is difficult terrain. Auto-fail checks requiring sight. Immune to visual effects. Overrides dazzled.",
|
||||||
iconName: "EyeOff",
|
iconName: "EyeOff",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
@@ -57,12 +90,15 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
|
||||||
iconName: "Heart",
|
iconName: "Heart",
|
||||||
color: "pink",
|
color: "pink",
|
||||||
|
systems: ["5e", "5.5e"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "deafened",
|
id: "deafened",
|
||||||
label: "Deafened",
|
label: "Deafened",
|
||||||
description: "Can't hear. Auto-fail hearing checks.",
|
description: "Can't hear. Auto-fail hearing checks.",
|
||||||
description5e: "Can't hear. Auto-fail hearing checks.",
|
description5e: "Can't hear. Auto-fail hearing checks.",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Can't hear. Auto-critically-fail hearing checks. –2 status penalty to Perception. Auditory actions require DC 5 flat check. Immune to auditory effects.",
|
||||||
iconName: "EarOff",
|
iconName: "EarOff",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
@@ -75,6 +111,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
"L1: Disadvantage on ability checks\nL2: Speed halved\nL3: Disadvantage on attacks and saves\nL4: HP max halved\nL5: Speed 0\nL6: Death\nLong rest removes 1 level.",
|
"L1: Disadvantage on ability checks\nL2: Speed halved\nL3: Disadvantage on attacks and saves\nL4: HP max halved\nL5: Speed 0\nL6: Death\nLong rest removes 1 level.",
|
||||||
iconName: "BatteryLow",
|
iconName: "BatteryLow",
|
||||||
color: "amber",
|
color: "amber",
|
||||||
|
systems: ["5e", "5.5e"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "frightened",
|
id: "frightened",
|
||||||
@@ -83,8 +120,11 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
||||||
description5e:
|
description5e:
|
||||||
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
|
||||||
|
descriptionPf2e:
|
||||||
|
"–X status penalty to all checks and DCs (X = value). Can't willingly approach the source.",
|
||||||
iconName: "Siren",
|
iconName: "Siren",
|
||||||
color: "orange",
|
color: "orange",
|
||||||
|
valued: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "grappled",
|
id: "grappled",
|
||||||
@@ -95,6 +135,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
"Speed 0. Ends if grappler is Incapacitated or moved out of reach.",
|
"Speed 0. Ends if grappler is Incapacitated or moved out of reach.",
|
||||||
iconName: "Hand",
|
iconName: "Hand",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
|
systems: ["5e", "5.5e"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "incapacitated",
|
id: "incapacitated",
|
||||||
@@ -104,6 +145,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
description5e: "Can't take Actions or Reactions.",
|
description5e: "Can't take Actions or Reactions.",
|
||||||
iconName: "Ban",
|
iconName: "Ban",
|
||||||
color: "gray",
|
color: "gray",
|
||||||
|
systems: ["5e", "5.5e"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "invisible",
|
id: "invisible",
|
||||||
@@ -112,6 +154,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
"Can't be seen. Advantage on Initiative. Not affected by effects requiring sight (unless caster sees you). Attacks have Advantage; attacks against have Disadvantage.",
|
"Can't be seen. Advantage on Initiative. Not affected by effects requiring sight (unless caster sees you). Attacks have Advantage; attacks against have Disadvantage.",
|
||||||
description5e:
|
description5e:
|
||||||
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
|
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Can't be seen except by special senses. Undetected to everyone. Can't be targeted except by effects that don't require sight.",
|
||||||
iconName: "Ghost",
|
iconName: "Ghost",
|
||||||
color: "violet",
|
color: "violet",
|
||||||
},
|
},
|
||||||
@@ -122,6 +166,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||||
description5e:
|
description5e:
|
||||||
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Can't act. Off-guard. Can only Recall Knowledge or use mental actions.",
|
||||||
iconName: "ZapOff",
|
iconName: "ZapOff",
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
},
|
},
|
||||||
@@ -132,6 +178,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
||||||
description5e:
|
description5e:
|
||||||
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Can't act. Can't sense anything. AC = 9. Hardness 8. Immune to most effects.",
|
||||||
iconName: "Gem",
|
iconName: "Gem",
|
||||||
color: "slate",
|
color: "slate",
|
||||||
},
|
},
|
||||||
@@ -142,6 +190,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
description5e: "Disadvantage on attack rolls and ability checks.",
|
description5e: "Disadvantage on attack rolls and ability checks.",
|
||||||
iconName: "Droplet",
|
iconName: "Droplet",
|
||||||
color: "green",
|
color: "green",
|
||||||
|
systems: ["5e", "5.5e"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "prone",
|
id: "prone",
|
||||||
@@ -150,6 +199,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
||||||
description5e:
|
description5e:
|
||||||
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Off-guard. –2 circumstance penalty to attack rolls. Only movement is Crawl and Stand. +1 circumstance bonus to AC vs. ranged attacks, –2 vs. melee.",
|
||||||
iconName: "ArrowDown",
|
iconName: "ArrowDown",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
@@ -160,6 +211,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||||
description5e:
|
description5e:
|
||||||
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Off-guard. Immobilized. Can't use any actions with the attack trait except to attempt to Escape.",
|
||||||
iconName: "Link",
|
iconName: "Link",
|
||||||
color: "neutral",
|
color: "neutral",
|
||||||
},
|
},
|
||||||
@@ -171,7 +224,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
description5e: "",
|
description5e: "",
|
||||||
iconName: "ShieldMinus",
|
iconName: "ShieldMinus",
|
||||||
color: "amber",
|
color: "amber",
|
||||||
edition: "5.5e",
|
systems: ["5.5e"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "slowed",
|
id: "slowed",
|
||||||
@@ -181,7 +234,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
description5e: "",
|
description5e: "",
|
||||||
iconName: "Snail",
|
iconName: "Snail",
|
||||||
color: "sky",
|
color: "sky",
|
||||||
edition: "5.5e",
|
systems: ["5.5e"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "stunned",
|
id: "stunned",
|
||||||
@@ -190,8 +243,11 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
"Incapacitated (can't act or speak). Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
"Incapacitated (can't act or speak). Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
||||||
description5e:
|
description5e:
|
||||||
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Can't act. Lose X total actions across turns, then the condition ends. Overrides slowed.",
|
||||||
iconName: "Sparkles",
|
iconName: "Sparkles",
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
|
valued: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "unconscious",
|
id: "unconscious",
|
||||||
@@ -200,9 +256,261 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||||
description5e:
|
description5e:
|
||||||
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Can't act. Off-guard. Blinded. –4 status penalty to AC, Perception, and Reflex saves. Fall prone, drop items.",
|
||||||
iconName: "Moon",
|
iconName: "Moon",
|
||||||
color: "indigo",
|
color: "indigo",
|
||||||
},
|
},
|
||||||
|
// ── PF2e-only conditions ──
|
||||||
|
{
|
||||||
|
id: "clumsy",
|
||||||
|
label: "Clumsy",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"–X status penalty to Dex-based checks and DCs, including AC, Reflex saves, and ranged attack rolls.",
|
||||||
|
iconName: "Footprints",
|
||||||
|
color: "amber",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
valued: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "concealed",
|
||||||
|
label: "Concealed",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"DC 5 flat check for targeted attacks. Doesn't change which creatures can see you.",
|
||||||
|
iconName: "CloudFog",
|
||||||
|
color: "slate",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "confused",
|
||||||
|
label: "Confused",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Off-guard. Can't Delay, Ready, or use reactions. Must Strike or cast offensive cantrips at random targets. DC 11 flat check when damaged to end.",
|
||||||
|
iconName: "CircleHelp",
|
||||||
|
color: "pink",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "controlled",
|
||||||
|
label: "Controlled",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Another creature determines your actions. You gain no actions of your own.",
|
||||||
|
iconName: "Drama",
|
||||||
|
color: "pink",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dazzled",
|
||||||
|
label: "Dazzled",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"All creatures and objects are concealed to you. DC 5 flat check for targeted attacks requiring sight.",
|
||||||
|
iconName: "Sun",
|
||||||
|
color: "yellow",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doomed",
|
||||||
|
label: "Doomed",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Die at dying X (where X = 4 – doomed value instead of dying 4). Decreases by 1 on full night's rest.",
|
||||||
|
iconName: "Skull",
|
||||||
|
color: "red",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
valued: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "drained",
|
||||||
|
label: "Drained",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"–X status penalty to Con-based checks and DCs. Lose X × level in max HP. Decreases by 1 on full night's rest.",
|
||||||
|
iconName: "Droplets",
|
||||||
|
color: "red",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
valued: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dying",
|
||||||
|
label: "Dying",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Unconscious. Make recovery checks at start of turn. At dying 4 (or 4 – doomed), you die.",
|
||||||
|
iconName: "HeartPulse",
|
||||||
|
color: "red",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
valued: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enfeebled",
|
||||||
|
label: "Enfeebled",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"–X status penalty to Str-based rolls and DCs, including melee attack and damage rolls and Athletics checks.",
|
||||||
|
iconName: "TrendingDown",
|
||||||
|
color: "amber",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
valued: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fascinated",
|
||||||
|
label: "Fascinated",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"–2 status penalty to Perception and skill checks. Can't use concentrate actions unless related to the fascination. Ends if hostile action is used against you or allies.",
|
||||||
|
iconName: "Eye",
|
||||||
|
color: "violet",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fatigued",
|
||||||
|
label: "Fatigued",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"–1 status penalty to AC and saves. Can't use exploration activities while traveling. Recover after a full night's rest.",
|
||||||
|
iconName: "BatteryLow",
|
||||||
|
color: "amber",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fleeing",
|
||||||
|
label: "Fleeing",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Must spend actions to move away from the source. Can't Delay or Ready.",
|
||||||
|
iconName: "PersonStanding",
|
||||||
|
color: "orange",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "grabbed",
|
||||||
|
label: "Grabbed",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Off-guard. Immobilized. Manipulate actions require DC 5 flat check or are wasted.",
|
||||||
|
iconName: "Hand",
|
||||||
|
color: "neutral",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hidden",
|
||||||
|
label: "Hidden",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Known location but can't be seen. Off-guard to that creature. DC 11 flat check to target or miss.",
|
||||||
|
iconName: "EyeOff",
|
||||||
|
color: "slate",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "immobilized",
|
||||||
|
label: "Immobilized",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Can't use any action with the move trait to change position.",
|
||||||
|
iconName: "Anchor",
|
||||||
|
color: "neutral",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "off-guard",
|
||||||
|
label: "Off-Guard",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e: "–2 circumstance penalty to AC. (Formerly flat-footed.)",
|
||||||
|
iconName: "ShieldOff",
|
||||||
|
color: "amber",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quickened",
|
||||||
|
label: "Quickened",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Gain 1 extra action at the start of your turn each round (limited uses specified by the effect).",
|
||||||
|
iconName: "Zap",
|
||||||
|
color: "green",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sickened",
|
||||||
|
label: "Sickened",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"–X status penalty to all checks and DCs. Can't willingly ingest anything. Reduce by retching (Fortitude save).",
|
||||||
|
iconName: "Droplet",
|
||||||
|
color: "green",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
valued: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slowed-pf2e",
|
||||||
|
label: "Slowed",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e: "Lose X actions at the start of your turn each round.",
|
||||||
|
iconName: "Snail",
|
||||||
|
color: "sky",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
valued: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stupefied",
|
||||||
|
label: "Stupefied",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"–X status penalty to Int/Wis/Cha-based checks and DCs, including spell attack rolls and spell DCs. DC 5 + X flat check to cast spells or lose the spell.",
|
||||||
|
iconName: "BrainCog",
|
||||||
|
color: "violet",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
valued: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "undetected",
|
||||||
|
label: "Undetected",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Location unknown. Must pick a square to target; DC 11 flat check. Attacker is off-guard against your attacks.",
|
||||||
|
iconName: "Ghost",
|
||||||
|
color: "violet",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wounded",
|
||||||
|
label: "Wounded",
|
||||||
|
description: "",
|
||||||
|
description5e: "",
|
||||||
|
descriptionPf2e:
|
||||||
|
"Next time you gain dying, add wounded value to dying value. Wounded 1 when you recover from dying; increases if already wounded.",
|
||||||
|
iconName: "HeartCrack",
|
||||||
|
color: "red",
|
||||||
|
systems: ["pf2e"],
|
||||||
|
valued: true,
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const VALID_CONDITION_IDS: ReadonlySet<string> = new Set(
|
export const VALID_CONDITION_IDS: ReadonlySet<string> = new Set(
|
||||||
@@ -213,6 +521,8 @@ export function getConditionsForEdition(
|
|||||||
edition: RulesEdition,
|
edition: RulesEdition,
|
||||||
): readonly ConditionDefinition[] {
|
): readonly ConditionDefinition[] {
|
||||||
return CONDITION_DEFINITIONS.filter(
|
return CONDITION_DEFINITIONS.filter(
|
||||||
(d) => d.edition === undefined || d.edition === edition,
|
(d) => d.systems === undefined || d.systems.includes(edition),
|
||||||
);
|
)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,18 @@ export function creatureId(id: string): CreatureId {
|
|||||||
return id as CreatureId;
|
return id as CreatureId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TraitSegment =
|
||||||
|
| { readonly type: "text"; readonly value: string }
|
||||||
|
| { readonly type: "list"; readonly items: readonly TraitListItem[] };
|
||||||
|
|
||||||
|
export interface TraitListItem {
|
||||||
|
readonly label?: string;
|
||||||
|
readonly text: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TraitBlock {
|
export interface TraitBlock {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly text: string;
|
readonly segments: readonly TraitSegment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LegendaryBlock {
|
export interface LegendaryBlock {
|
||||||
@@ -92,6 +101,62 @@ export interface BestiaryIndex {
|
|||||||
readonly creatures: readonly BestiaryIndexEntry[];
|
readonly creatures: readonly BestiaryIndexEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Pf2eCreature {
|
||||||
|
readonly system: "pf2e";
|
||||||
|
readonly id: CreatureId;
|
||||||
|
readonly name: string;
|
||||||
|
readonly source: string;
|
||||||
|
readonly sourceDisplayName: string;
|
||||||
|
readonly level: number;
|
||||||
|
readonly traits: readonly string[];
|
||||||
|
readonly perception: number;
|
||||||
|
readonly senses?: string;
|
||||||
|
readonly languages?: string;
|
||||||
|
readonly skills?: string;
|
||||||
|
readonly abilityMods: {
|
||||||
|
readonly str: number;
|
||||||
|
readonly dex: number;
|
||||||
|
readonly con: number;
|
||||||
|
readonly int: number;
|
||||||
|
readonly wis: number;
|
||||||
|
readonly cha: number;
|
||||||
|
};
|
||||||
|
readonly items?: string;
|
||||||
|
readonly ac: number;
|
||||||
|
readonly acConditional?: string;
|
||||||
|
readonly saveFort: number;
|
||||||
|
readonly saveRef: number;
|
||||||
|
readonly saveWill: number;
|
||||||
|
readonly hp: number;
|
||||||
|
readonly immunities?: string;
|
||||||
|
readonly resistances?: string;
|
||||||
|
readonly weaknesses?: string;
|
||||||
|
readonly speed: string;
|
||||||
|
readonly attacks?: readonly TraitBlock[];
|
||||||
|
readonly abilitiesTop?: readonly TraitBlock[];
|
||||||
|
readonly abilitiesMid?: readonly TraitBlock[];
|
||||||
|
readonly abilitiesBot?: readonly TraitBlock[];
|
||||||
|
readonly spellcasting?: readonly SpellcastingBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnyCreature = Creature | Pf2eCreature;
|
||||||
|
|
||||||
|
export interface Pf2eBestiaryIndexEntry {
|
||||||
|
readonly name: string;
|
||||||
|
readonly source: string;
|
||||||
|
readonly level: number;
|
||||||
|
readonly ac: number;
|
||||||
|
readonly hp: number;
|
||||||
|
readonly perception: number;
|
||||||
|
readonly size: string;
|
||||||
|
readonly type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pf2eBestiaryIndex {
|
||||||
|
readonly sources: Readonly<Record<string, string>>;
|
||||||
|
readonly creatures: readonly Pf2eBestiaryIndexEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Maps a CR string to the corresponding proficiency bonus. */
|
/** Maps a CR string to the corresponding proficiency bonus. */
|
||||||
export function proficiencyBonus(cr: string): number {
|
export function proficiencyBonus(cr: string): number {
|
||||||
const numericCr = cr.includes("/")
|
const numericCr = cr.includes("/")
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
export type DifficultyTier = "trivial" | "low" | "moderate" | "high";
|
import type { RulesEdition } from "./rules-edition.js";
|
||||||
|
|
||||||
|
/** Abstract difficulty severity: 0 = negligible, 3 = maximum. Maps to filled bar count. */
|
||||||
|
export type DifficultyTier = 0 | 1 | 2 | 3;
|
||||||
|
|
||||||
|
export interface DifficultyThreshold {
|
||||||
|
readonly label: string;
|
||||||
|
readonly value: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DifficultyResult {
|
export interface DifficultyResult {
|
||||||
readonly tier: DifficultyTier;
|
readonly tier: DifficultyTier;
|
||||||
readonly totalMonsterXp: number;
|
readonly totalMonsterXp: number;
|
||||||
readonly partyBudget: {
|
readonly thresholds: readonly DifficultyThreshold[];
|
||||||
readonly low: number;
|
/** 2014 only: the encounter multiplier applied to base monster XP. */
|
||||||
readonly moderate: number;
|
readonly encounterMultiplier: number | undefined;
|
||||||
readonly high: number;
|
/** 2014 only: monster XP after applying the encounter multiplier. */
|
||||||
};
|
readonly adjustedXp: number | undefined;
|
||||||
|
/** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */
|
||||||
|
readonly partySizeAdjusted: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps challenge rating strings to XP values (standard 5e). */
|
/** Maps challenge rating strings to XP values (standard 5e). */
|
||||||
@@ -74,53 +84,215 @@ const XP_BUDGET_PER_CHARACTER: Readonly<
|
|||||||
20: { low: 6400, moderate: 13200, high: 22000 },
|
20: { low: 6400, moderate: 13200, high: 22000 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Maps character level (1-20) to XP thresholds (2014 DMG). */
|
||||||
|
const XP_THRESHOLDS_2014: Readonly<
|
||||||
|
Record<number, { easy: number; medium: number; hard: number; deadly: number }>
|
||||||
|
> = {
|
||||||
|
1: { easy: 25, medium: 50, hard: 75, deadly: 100 },
|
||||||
|
2: { easy: 50, medium: 100, hard: 150, deadly: 200 },
|
||||||
|
3: { easy: 75, medium: 150, hard: 225, deadly: 400 },
|
||||||
|
4: { easy: 125, medium: 250, hard: 375, deadly: 500 },
|
||||||
|
5: { easy: 250, medium: 500, hard: 750, deadly: 1100 },
|
||||||
|
6: { easy: 300, medium: 600, hard: 900, deadly: 1400 },
|
||||||
|
7: { easy: 350, medium: 750, hard: 1100, deadly: 1700 },
|
||||||
|
8: { easy: 450, medium: 900, hard: 1400, deadly: 2100 },
|
||||||
|
9: { easy: 550, medium: 1100, hard: 1600, deadly: 2400 },
|
||||||
|
10: { easy: 600, medium: 1200, hard: 1900, deadly: 2800 },
|
||||||
|
11: { easy: 800, medium: 1600, hard: 2400, deadly: 3600 },
|
||||||
|
12: { easy: 1000, medium: 2000, hard: 3000, deadly: 4500 },
|
||||||
|
13: { easy: 1100, medium: 2200, hard: 3400, deadly: 5100 },
|
||||||
|
14: { easy: 1250, medium: 2500, hard: 3800, deadly: 5700 },
|
||||||
|
15: { easy: 1400, medium: 2800, hard: 4300, deadly: 6400 },
|
||||||
|
16: { easy: 1600, medium: 3200, hard: 4800, deadly: 7200 },
|
||||||
|
17: { easy: 2000, medium: 3900, hard: 5900, deadly: 8800 },
|
||||||
|
18: { easy: 2100, medium: 4200, hard: 6300, deadly: 9500 },
|
||||||
|
19: { easy: 2400, medium: 4900, hard: 7300, deadly: 10900 },
|
||||||
|
20: { easy: 2800, medium: 5700, hard: 8500, deadly: 12700 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 2014 encounter multiplier by number of enemy-side monsters. */
|
||||||
|
const ENCOUNTER_MULTIPLIER_TABLE: readonly {
|
||||||
|
max: number;
|
||||||
|
multiplier: number;
|
||||||
|
}[] = [
|
||||||
|
{ max: 1, multiplier: 1 },
|
||||||
|
{ max: 2, multiplier: 1.5 },
|
||||||
|
{ max: 6, multiplier: 2 },
|
||||||
|
{ max: 10, multiplier: 2.5 },
|
||||||
|
{ max: 14, multiplier: 3 },
|
||||||
|
{ max: Number.POSITIVE_INFINITY, multiplier: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multiplier values in ascending order for party size shifting.
|
||||||
|
* Extends beyond the base table: x0.5 (6+ PCs, 1 monster) and x5 (<3 PCs, 15+ monsters)
|
||||||
|
* per 2014 DMG party size adjustment rules.
|
||||||
|
*/
|
||||||
|
const MULTIPLIER_STEPS = [0.5, 1, 1.5, 2, 2.5, 3, 4, 5] as const;
|
||||||
|
|
||||||
|
/** Index into MULTIPLIER_STEPS for each base table entry (before party size adjustment). */
|
||||||
|
const BASE_STEP_INDEX = [1, 2, 3, 4, 5, 6] as const;
|
||||||
|
|
||||||
|
function getEncounterMultiplier(
|
||||||
|
monsterCount: number,
|
||||||
|
partySize: number,
|
||||||
|
): { multiplier: number; partySizeAdjusted: boolean } {
|
||||||
|
const tableIndex = ENCOUNTER_MULTIPLIER_TABLE.findIndex(
|
||||||
|
(entry) => monsterCount <= entry.max,
|
||||||
|
);
|
||||||
|
let stepIndex: number =
|
||||||
|
BASE_STEP_INDEX[
|
||||||
|
tableIndex === -1 ? BASE_STEP_INDEX.length - 1 : tableIndex
|
||||||
|
];
|
||||||
|
let partySizeAdjusted = false;
|
||||||
|
|
||||||
|
if (partySize < 3) {
|
||||||
|
stepIndex = Math.min(stepIndex + 1, MULTIPLIER_STEPS.length - 1);
|
||||||
|
partySizeAdjusted = true;
|
||||||
|
} else if (partySize >= 6) {
|
||||||
|
stepIndex = Math.max(stepIndex - 1, 0);
|
||||||
|
partySizeAdjusted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
multiplier: MULTIPLIER_STEPS[stepIndex] as number,
|
||||||
|
partySizeAdjusted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All standard 5e challenge rating strings, in ascending order. */
|
||||||
|
export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP);
|
||||||
|
|
||||||
/** Returns the XP value for a given CR string. Returns 0 for unknown CRs. */
|
/** Returns the XP value for a given CR string. Returns 0 for unknown CRs. */
|
||||||
export function crToXp(cr: string): number {
|
export function crToXp(cr: string): number {
|
||||||
return CR_TO_XP[cr] ?? 0;
|
return CR_TO_XP[cr] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface CombatantDescriptor {
|
||||||
* Calculates encounter difficulty from party levels and monster CRs.
|
readonly level?: number;
|
||||||
* Both arrays should be pre-filtered (only PCs with levels, only bestiary-linked monsters).
|
readonly cr?: string;
|
||||||
*/
|
readonly side: "party" | "enemy";
|
||||||
export function calculateEncounterDifficulty(
|
|
||||||
partyLevels: readonly number[],
|
|
||||||
monsterCrs: readonly string[],
|
|
||||||
): DifficultyResult {
|
|
||||||
let budgetLow = 0;
|
|
||||||
let budgetModerate = 0;
|
|
||||||
let budgetHigh = 0;
|
|
||||||
|
|
||||||
for (const level of partyLevels) {
|
|
||||||
const budget = XP_BUDGET_PER_CHARACTER[level];
|
|
||||||
if (budget) {
|
|
||||||
budgetLow += budget.low;
|
|
||||||
budgetModerate += budget.moderate;
|
|
||||||
budgetHigh += budget.high;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function determineTier(
|
||||||
|
xp: number,
|
||||||
|
tierThresholds: readonly number[],
|
||||||
|
): DifficultyTier {
|
||||||
|
for (let i = tierThresholds.length - 1; i >= 0; i--) {
|
||||||
|
if (xp >= tierThresholds[i]) return (i + 1) as DifficultyTier;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function accumulateBudget5_5e(levels: readonly number[]) {
|
||||||
|
const budget = { low: 0, moderate: 0, high: 0 };
|
||||||
|
for (const level of levels) {
|
||||||
|
const b = XP_BUDGET_PER_CHARACTER[level];
|
||||||
|
if (b) {
|
||||||
|
budget.low += b.low;
|
||||||
|
budget.moderate += b.moderate;
|
||||||
|
budget.high += b.high;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function accumulateBudget2014(levels: readonly number[]) {
|
||||||
|
const budget = { easy: 0, medium: 0, hard: 0, deadly: 0 };
|
||||||
|
for (const level of levels) {
|
||||||
|
const b = XP_THRESHOLDS_2014[level];
|
||||||
|
if (b) {
|
||||||
|
budget.easy += b.easy;
|
||||||
|
budget.medium += b.medium;
|
||||||
|
budget.hard += b.hard;
|
||||||
|
budget.deadly += b.deadly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanCombatants(combatants: readonly CombatantDescriptor[]) {
|
||||||
let totalMonsterXp = 0;
|
let totalMonsterXp = 0;
|
||||||
for (const cr of monsterCrs) {
|
let monsterCount = 0;
|
||||||
totalMonsterXp += crToXp(cr);
|
const partyLevels: number[] = [];
|
||||||
}
|
|
||||||
|
|
||||||
let tier: DifficultyTier = "trivial";
|
for (const c of combatants) {
|
||||||
if (totalMonsterXp >= budgetHigh) {
|
if (c.level !== undefined && c.side === "party") {
|
||||||
tier = "high";
|
partyLevels.push(c.level);
|
||||||
} else if (totalMonsterXp >= budgetModerate) {
|
}
|
||||||
tier = "moderate";
|
if (c.cr !== undefined) {
|
||||||
} else if (totalMonsterXp >= budgetLow) {
|
const xp = crToXp(c.cr);
|
||||||
tier = "low";
|
if (c.side === "enemy") {
|
||||||
|
totalMonsterXp += xp;
|
||||||
|
monsterCount++;
|
||||||
|
} else {
|
||||||
|
totalMonsterXp -= xp;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tier,
|
totalMonsterXp: Math.max(0, totalMonsterXp),
|
||||||
totalMonsterXp,
|
monsterCount,
|
||||||
partyBudget: {
|
partyLevels,
|
||||||
low: budgetLow,
|
};
|
||||||
moderate: budgetModerate,
|
}
|
||||||
high: budgetHigh,
|
|
||||||
},
|
/**
|
||||||
|
* Calculates encounter difficulty from combatant descriptors.
|
||||||
|
* Party-side combatants with level contribute to the budget.
|
||||||
|
* Enemy-side combatants with CR add XP; party-side with CR subtract XP (floored at 0).
|
||||||
|
*/
|
||||||
|
export function calculateEncounterDifficulty(
|
||||||
|
combatants: readonly CombatantDescriptor[],
|
||||||
|
edition: RulesEdition,
|
||||||
|
): DifficultyResult {
|
||||||
|
const { totalMonsterXp, monsterCount, partyLevels } =
|
||||||
|
scanCombatants(combatants);
|
||||||
|
|
||||||
|
if (edition === "5.5e") {
|
||||||
|
const budget = accumulateBudget5_5e(partyLevels);
|
||||||
|
const thresholds: DifficultyThreshold[] = [
|
||||||
|
{ label: "Low", value: budget.low },
|
||||||
|
{ label: "Moderate", value: budget.moderate },
|
||||||
|
{ label: "High", value: budget.high },
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
tier: determineTier(totalMonsterXp, [
|
||||||
|
budget.low,
|
||||||
|
budget.moderate,
|
||||||
|
budget.high,
|
||||||
|
]),
|
||||||
|
totalMonsterXp,
|
||||||
|
thresholds,
|
||||||
|
encounterMultiplier: undefined,
|
||||||
|
adjustedXp: undefined,
|
||||||
|
partySizeAdjusted: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2014 edition
|
||||||
|
const budget = accumulateBudget2014(partyLevels);
|
||||||
|
const { multiplier: encounterMultiplier, partySizeAdjusted } =
|
||||||
|
getEncounterMultiplier(monsterCount, partyLevels.length);
|
||||||
|
const adjustedXp = Math.round(totalMonsterXp * encounterMultiplier);
|
||||||
|
const thresholds: DifficultyThreshold[] = [
|
||||||
|
{ label: "Easy", value: budget.easy },
|
||||||
|
{ label: "Medium", value: budget.medium },
|
||||||
|
{ label: "Hard", value: budget.hard },
|
||||||
|
{ label: "Deadly", value: budget.deadly },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
tier: determineTier(adjustedXp, [
|
||||||
|
budget.medium,
|
||||||
|
budget.hard,
|
||||||
|
budget.deadly,
|
||||||
|
]),
|
||||||
|
totalMonsterXp,
|
||||||
|
thresholds,
|
||||||
|
encounterMultiplier,
|
||||||
|
adjustedXp,
|
||||||
|
partySizeAdjusted,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,16 +94,32 @@ export interface AcSet {
|
|||||||
readonly newAc: number | undefined;
|
readonly newAc: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CrSet {
|
||||||
|
readonly type: "CrSet";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
readonly previousCr: string | undefined;
|
||||||
|
readonly newCr: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SideSet {
|
||||||
|
readonly type: "SideSet";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
readonly previousSide: "party" | "enemy" | undefined;
|
||||||
|
readonly newSide: "party" | "enemy";
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConditionAdded {
|
export interface ConditionAdded {
|
||||||
readonly type: "ConditionAdded";
|
readonly type: "ConditionAdded";
|
||||||
readonly combatantId: CombatantId;
|
readonly combatantId: CombatantId;
|
||||||
readonly condition: ConditionId;
|
readonly condition: ConditionId;
|
||||||
|
readonly value?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConditionRemoved {
|
export interface ConditionRemoved {
|
||||||
readonly type: "ConditionRemoved";
|
readonly type: "ConditionRemoved";
|
||||||
readonly combatantId: CombatantId;
|
readonly combatantId: CombatantId;
|
||||||
readonly condition: ConditionId;
|
readonly condition: ConditionId;
|
||||||
|
readonly value?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConcentrationStarted {
|
export interface ConcentrationStarted {
|
||||||
@@ -153,6 +169,8 @@ export type DomainEvent =
|
|||||||
| TurnRetreated
|
| TurnRetreated
|
||||||
| RoundRetreated
|
| RoundRetreated
|
||||||
| AcSet
|
| AcSet
|
||||||
|
| CrSet
|
||||||
|
| SideSet
|
||||||
| ConditionAdded
|
| ConditionAdded
|
||||||
| ConditionRemoved
|
| ConditionRemoved
|
||||||
| ConcentrationStarted
|
| ConcentrationStarted
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ export {
|
|||||||
export {
|
export {
|
||||||
CONDITION_DEFINITIONS,
|
CONDITION_DEFINITIONS,
|
||||||
type ConditionDefinition,
|
type ConditionDefinition,
|
||||||
|
type ConditionEntry,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
getConditionsForEdition,
|
getConditionsForEdition,
|
||||||
type RulesEdition,
|
|
||||||
VALID_CONDITION_IDS,
|
VALID_CONDITION_IDS,
|
||||||
} from "./conditions.js";
|
} from "./conditions.js";
|
||||||
export {
|
export {
|
||||||
@@ -24,6 +24,7 @@ export {
|
|||||||
createPlayerCharacter,
|
createPlayerCharacter,
|
||||||
} from "./create-player-character.js";
|
} from "./create-player-character.js";
|
||||||
export {
|
export {
|
||||||
|
type AnyCreature,
|
||||||
type BestiaryIndex,
|
type BestiaryIndex,
|
||||||
type BestiaryIndexEntry,
|
type BestiaryIndexEntry,
|
||||||
type BestiarySource,
|
type BestiarySource,
|
||||||
@@ -32,9 +33,14 @@ export {
|
|||||||
creatureId,
|
creatureId,
|
||||||
type DailySpells,
|
type DailySpells,
|
||||||
type LegendaryBlock,
|
type LegendaryBlock,
|
||||||
|
type Pf2eBestiaryIndex,
|
||||||
|
type Pf2eBestiaryIndexEntry,
|
||||||
|
type Pf2eCreature,
|
||||||
proficiencyBonus,
|
proficiencyBonus,
|
||||||
type SpellcastingBlock,
|
type SpellcastingBlock,
|
||||||
type TraitBlock,
|
type TraitBlock,
|
||||||
|
type TraitListItem,
|
||||||
|
type TraitSegment,
|
||||||
} from "./creature-types.js";
|
} from "./creature-types.js";
|
||||||
export {
|
export {
|
||||||
type DeletePlayerCharacterSuccess,
|
type DeletePlayerCharacterSuccess,
|
||||||
@@ -49,10 +55,13 @@ export {
|
|||||||
editPlayerCharacter,
|
editPlayerCharacter,
|
||||||
} from "./edit-player-character.js";
|
} from "./edit-player-character.js";
|
||||||
export {
|
export {
|
||||||
|
type CombatantDescriptor,
|
||||||
calculateEncounterDifficulty,
|
calculateEncounterDifficulty,
|
||||||
crToXp,
|
crToXp,
|
||||||
type DifficultyResult,
|
type DifficultyResult,
|
||||||
|
type DifficultyThreshold,
|
||||||
type DifficultyTier,
|
type DifficultyTier,
|
||||||
|
VALID_CR_VALUES,
|
||||||
} from "./encounter-difficulty.js";
|
} from "./encounter-difficulty.js";
|
||||||
export type {
|
export type {
|
||||||
AcSet,
|
AcSet,
|
||||||
@@ -63,6 +72,7 @@ export type {
|
|||||||
ConcentrationStarted,
|
ConcentrationStarted,
|
||||||
ConditionAdded,
|
ConditionAdded,
|
||||||
ConditionRemoved,
|
ConditionRemoved,
|
||||||
|
CrSet,
|
||||||
CurrentHpAdjusted,
|
CurrentHpAdjusted,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
EncounterCleared,
|
EncounterCleared,
|
||||||
@@ -73,6 +83,7 @@ export type {
|
|||||||
PlayerCharacterUpdated,
|
PlayerCharacterUpdated,
|
||||||
RoundAdvanced,
|
RoundAdvanced,
|
||||||
RoundRetreated,
|
RoundRetreated,
|
||||||
|
SideSet,
|
||||||
TempHpSet,
|
TempHpSet,
|
||||||
TurnAdvanced,
|
TurnAdvanced,
|
||||||
TurnRetreated,
|
TurnRetreated,
|
||||||
@@ -81,6 +92,7 @@ export type { ExportBundle } from "./export-bundle.js";
|
|||||||
export { deriveHpStatus, type HpStatus } from "./hp-status.js";
|
export { deriveHpStatus, type HpStatus } from "./hp-status.js";
|
||||||
export {
|
export {
|
||||||
calculateInitiative,
|
calculateInitiative,
|
||||||
|
calculatePf2eInitiative,
|
||||||
formatInitiativeModifier,
|
formatInitiativeModifier,
|
||||||
type InitiativeResult,
|
type InitiativeResult,
|
||||||
} from "./initiative.js";
|
} from "./initiative.js";
|
||||||
@@ -106,18 +118,23 @@ export {
|
|||||||
rollInitiative,
|
rollInitiative,
|
||||||
selectRoll,
|
selectRoll,
|
||||||
} from "./roll-initiative.js";
|
} from "./roll-initiative.js";
|
||||||
|
export type { RulesEdition } from "./rules-edition.js";
|
||||||
export { type SetAcSuccess, setAc } from "./set-ac.js";
|
export { type SetAcSuccess, setAc } from "./set-ac.js";
|
||||||
|
export { type SetCrSuccess, setCr } from "./set-cr.js";
|
||||||
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
||||||
export {
|
export {
|
||||||
type SetInitiativeSuccess,
|
type SetInitiativeSuccess,
|
||||||
setInitiative,
|
setInitiative,
|
||||||
} from "./set-initiative.js";
|
} from "./set-initiative.js";
|
||||||
|
export { type SetSideSuccess, setSide } from "./set-side.js";
|
||||||
export { type SetTempHpSuccess, setTempHp } from "./set-temp-hp.js";
|
export { type SetTempHpSuccess, setTempHp } from "./set-temp-hp.js";
|
||||||
export {
|
export {
|
||||||
type ToggleConcentrationSuccess,
|
type ToggleConcentrationSuccess,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
} from "./toggle-concentration.js";
|
} from "./toggle-concentration.js";
|
||||||
export {
|
export {
|
||||||
|
decrementCondition,
|
||||||
|
setConditionValue,
|
||||||
type ToggleConditionSuccess,
|
type ToggleConditionSuccess,
|
||||||
toggleCondition,
|
toggleCondition,
|
||||||
} from "./toggle-condition.js";
|
} from "./toggle-condition.js";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user