Add Pathfinder 2e game system mode
Implements PF2e as an alternative game system alongside D&D 5e/5.5e. Settings modal "Game System" selector switches conditions, bestiary, stat block layout, and initiative calculation between systems. - Valued conditions with increment/decrement UX (Clumsy 2, Frightened 3) - 2,502 PF2e creatures from bundled search index (77 sources) - PF2e stat block: level, traits, Perception, Fort/Ref/Will, ability mods - Perception-based initiative rolling - System-scoped source cache (D&D and PF2e sources don't collide) - Backwards-compatible condition rehydration (ConditionId[] → ConditionEntry[]) - Difficulty indicator hidden in PF2e mode (excluded from MVP) Closes dostulata/initiative#19 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||
import { BulkImportPrompt } from "../bulk-import-prompt.js";
|
||||
|
||||
const THREE_SOURCES_REGEX = /3 sources/;
|
||||
@@ -68,9 +69,11 @@ function createAdaptersWithSources() {
|
||||
function renderWithAdapters() {
|
||||
const adapters = createAdaptersWithSources();
|
||||
return render(
|
||||
<AdapterProvider adapters={adapters}>
|
||||
<BulkImportPrompt />
|
||||
</AdapterProvider>,
|
||||
<RulesEditionProvider>
|
||||
<AdapterProvider adapters={adapters}>
|
||||
<BulkImportPrompt />
|
||||
</AdapterProvider>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
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 userEvent from "@testing-library/user-event";
|
||||
import { createRef, type RefObject } from "react";
|
||||
@@ -13,12 +17,14 @@ afterEach(cleanup);
|
||||
|
||||
function renderPicker(
|
||||
overrides: Partial<{
|
||||
activeConditions: readonly ConditionId[];
|
||||
activeConditions: readonly ConditionEntry[];
|
||||
onToggle: (conditionId: ConditionId) => void;
|
||||
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||
onClose: () => void;
|
||||
}> = {},
|
||||
) {
|
||||
const onToggle = overrides.onToggle ?? vi.fn();
|
||||
const onSetValue = overrides.onSetValue ?? vi.fn();
|
||||
const onClose = overrides.onClose ?? vi.fn();
|
||||
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
||||
const anchor = document.createElement("div");
|
||||
@@ -30,25 +36,27 @@ function renderPicker(
|
||||
anchorRef={anchorRef}
|
||||
activeConditions={overrides.activeConditions ?? []}
|
||||
onToggle={onToggle}
|
||||
onSetValue={onSetValue}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
return { ...result, onToggle, onClose };
|
||||
return { ...result, onToggle, onSetValue, onClose };
|
||||
}
|
||||
|
||||
describe("ConditionPicker", () => {
|
||||
it("renders all condition definitions from domain", () => {
|
||||
it("renders edition-specific conditions from domain", () => {
|
||||
renderPicker();
|
||||
for (const def of CONDITION_DEFINITIONS) {
|
||||
const editionConditions = getConditionsForEdition("5.5e");
|
||||
for (const def of editionConditions) {
|
||||
expect(screen.getByText(def.label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("active conditions are visually distinguished", () => {
|
||||
renderPicker({ activeConditions: ["blinded"] });
|
||||
const blindedButton = screen.getByText("Blinded").closest("button");
|
||||
expect(blindedButton?.className).toContain("bg-card/50");
|
||||
renderPicker({ activeConditions: [{ id: "blinded" }] });
|
||||
const row = screen.getByText("Blinded").closest("div[class]");
|
||||
expect(row?.className).toContain("bg-card/50");
|
||||
});
|
||||
|
||||
it("clicking a condition calls onToggle with that condition's ID", async () => {
|
||||
@@ -65,7 +73,7 @@ describe("ConditionPicker", () => {
|
||||
});
|
||||
|
||||
it("active condition labels use foreground color", () => {
|
||||
renderPicker({ activeConditions: ["charmed"] });
|
||||
renderPicker({ activeConditions: [{ id: "charmed" }] });
|
||||
const label = screen.getByText("Charmed");
|
||||
expect(label.className).toContain("text-foreground");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { ConditionId } from "@initiative/domain";
|
||||
import type { ConditionEntry } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -14,6 +14,7 @@ function renderTags(props: Partial<Parameters<typeof ConditionTags>[0]> = {}) {
|
||||
<ConditionTags
|
||||
conditions={props.conditions}
|
||||
onRemove={props.onRemove ?? (() => {})}
|
||||
onDecrement={props.onDecrement ?? (() => {})}
|
||||
onOpenPicker={props.onOpenPicker ?? (() => {})}
|
||||
/>
|
||||
</RulesEditionProvider>,
|
||||
@@ -28,7 +29,7 @@ describe("ConditionTags", () => {
|
||||
});
|
||||
|
||||
it("renders a button per condition", () => {
|
||||
const conditions: ConditionId[] = ["blinded", "prone"];
|
||||
const conditions: ConditionEntry[] = [{ id: "blinded" }, { id: "prone" }];
|
||||
renderTags({ conditions });
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||
@@ -39,7 +40,7 @@ describe("ConditionTags", () => {
|
||||
it("calls onRemove with condition id when clicked", async () => {
|
||||
const onRemove = vi.fn();
|
||||
renderTags({
|
||||
conditions: ["blinded"] as ConditionId[],
|
||||
conditions: [{ id: "blinded" }] as ConditionEntry[],
|
||||
onRemove,
|
||||
});
|
||||
|
||||
@@ -66,4 +67,37 @@ describe("ConditionTags", () => {
|
||||
// Only add button
|
||||
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays value badge for valued conditions", () => {
|
||||
renderTags({ conditions: [{ id: "frightened", value: 3 }] });
|
||||
expect(screen.getByText("3")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onDecrement for valued condition click", async () => {
|
||||
const onDecrement = vi.fn();
|
||||
renderTags({
|
||||
conditions: [{ id: "frightened", value: 2 }],
|
||||
onDecrement,
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Remove Frightened" }),
|
||||
);
|
||||
|
||||
expect(onDecrement).toHaveBeenCalledWith("frightened");
|
||||
});
|
||||
|
||||
it("calls onRemove for non-valued condition click", async () => {
|
||||
const onRemove = vi.fn();
|
||||
renderTags({
|
||||
conditions: [{ id: "blinded" }],
|
||||
onRemove,
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||
);
|
||||
|
||||
expect(onRemove).toHaveBeenCalledWith("blinded");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,15 +37,18 @@ function renderModal(open = true) {
|
||||
}
|
||||
|
||||
describe("SettingsModal", () => {
|
||||
it("renders edition section with 'Rules Edition' label", () => {
|
||||
it("renders game system section with all three options", () => {
|
||||
renderModal();
|
||||
expect(screen.getByText("Rules Edition")).toBeInTheDocument();
|
||||
expect(screen.getByText("Game System")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "5e (2014)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "5.5e (2024)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Pathfinder 2e" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders theme toggle buttons", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
|
||||
|
||||
const MONSTER_MANUAL_REGEX = /Monster Manual/;
|
||||
@@ -36,12 +37,14 @@ function renderPrompt(sourceCode = "MM") {
|
||||
code === "MM" ? "Monster Manual" : code,
|
||||
};
|
||||
const result = render(
|
||||
<AdapterProvider adapters={adapters}>
|
||||
<SourceFetchPrompt
|
||||
sourceCode={sourceCode}
|
||||
onSourceLoaded={onSourceLoaded}
|
||||
/>
|
||||
</AdapterProvider>,
|
||||
<RulesEditionProvider>
|
||||
<AdapterProvider adapters={adapters}>
|
||||
<SourceFetchPrompt
|
||||
sourceCode={sourceCode}
|
||||
onSourceLoaded={onSourceLoaded}
|
||||
/>
|
||||
</AdapterProvider>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
return { ...result, onSourceLoaded };
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ function renderWithSources(sources: CachedSourceInfo[] = []) {
|
||||
adapters.bestiaryCache = {
|
||||
...adapters.bestiaryCache,
|
||||
getCachedSources: () => Promise.resolve(currentSources),
|
||||
clearSource(sourceCode) {
|
||||
clearSource(_system, sourceCode) {
|
||||
currentSources = currentSources.filter(
|
||||
(s) => s.sourceCode !== sourceCode,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Creature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { StatBlock } from "../stat-block.js";
|
||||
import { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user