Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c029c0ca8d | ||
|
|
2e4865d5fc | ||
|
|
be3778a0c9 | ||
|
|
7b599e9ad9 | ||
|
|
5d99764e14 | ||
|
|
67c398d3a2 | ||
|
|
8204122bd0 | ||
|
|
685526d53b | ||
|
|
91f46ff3c8 | ||
|
|
90eb39b227 | ||
|
|
78079bf1b2 |
+9
-3
@@ -1,8 +1,14 @@
|
||||
{
|
||||
"threshold": 5,
|
||||
"threshold": 3,
|
||||
"minLines": 5,
|
||||
"minTokens": 50,
|
||||
"pattern": ["**/*.ts", "**/*.tsx"],
|
||||
"ignore": ["node_modules", "dist", "build", "coverage", ".specify", "specs"],
|
||||
"pattern": "**/*.{ts,tsx}",
|
||||
"ignore": [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/coverage/**",
|
||||
"**/__tests__/**"
|
||||
],
|
||||
"reporters": ["console"]
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-type-annotations/refs/heads/main/packages/oxlint/configuration_file_schema.json",
|
||||
"plugins": ["typescript", "unicorn", "jest"],
|
||||
"plugins": ["typescript", "unicorn", "jest", "import"],
|
||||
"categories": {},
|
||||
"rules": {
|
||||
"typescript/no-unnecessary-type-assertion": "error",
|
||||
@@ -8,6 +8,7 @@
|
||||
"typescript/prefer-regexp-exec": "error",
|
||||
"unicorn/prefer-string-replace-all": "error",
|
||||
"unicorn/prefer-string-raw": "error",
|
||||
"import/no-cycle": "error",
|
||||
"jest/expect-expect": [
|
||||
"error",
|
||||
{
|
||||
@@ -21,7 +22,6 @@
|
||||
".claude",
|
||||
".specify",
|
||||
"specs",
|
||||
".pnpm-store",
|
||||
"scripts"
|
||||
".pnpm-store"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"jsdom": "^30.0.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
|
||||
@@ -266,6 +266,45 @@ describe("round-trip: export then import", () => {
|
||||
expect(imported.encounter.combatants[1].side).toBe("enemy");
|
||||
});
|
||||
|
||||
it("round-trips a combatant with hpVariant field", () => {
|
||||
const encounterWithVariant: Encounter = {
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name: "Ogre",
|
||||
maxHp: 24,
|
||||
currentHp: 24,
|
||||
hpVariant: "max",
|
||||
},
|
||||
{
|
||||
id: combatantId("c-2"),
|
||||
name: "Goblin",
|
||||
maxHp: 2,
|
||||
currentHp: 2,
|
||||
hpVariant: "min",
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const emptyUndoRedo: UndoRedoState = {
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
const bundle = assembleExportBundle(
|
||||
encounterWithVariant,
|
||||
emptyUndoRedo,
|
||||
[],
|
||||
);
|
||||
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||
const result = validateImportBundle(serialized);
|
||||
|
||||
expect(typeof result).toBe("object");
|
||||
const imported = result as ExportBundle;
|
||||
expect(imported.encounter.combatants[0].hpVariant).toBe("max");
|
||||
expect(imported.encounter.combatants[1].hpVariant).toBe("min");
|
||||
});
|
||||
|
||||
it("round-trips a combatant without side field as undefined", () => {
|
||||
const encounterNoSide: Encounter = {
|
||||
combatants: [{ id: combatantId("c-1"), name: "Custom" }],
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { playerCharacterId } from "@initiative/domain";
|
||||
|
||||
let counter = 0;
|
||||
|
||||
export function buildPlayerCharacter(
|
||||
overrides?: Partial<PlayerCharacter>,
|
||||
): PlayerCharacter {
|
||||
return {
|
||||
id: playerCharacterId(`pc-${++counter}`),
|
||||
name: "Player Character",
|
||||
ac: 15,
|
||||
maxHp: 25,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export { buildCombatant } from "./build-combatant.js";
|
||||
export { buildCreature } from "./build-creature.js";
|
||||
export { buildEncounter } from "./build-encounter.js";
|
||||
export { buildPf2eCreature } from "./build-pf2e-creature.js";
|
||||
export { buildPlayerCharacter } from "./build-player-character.js";
|
||||
|
||||
@@ -9,18 +9,18 @@ import type {
|
||||
} from "@initiative/domain";
|
||||
|
||||
export interface EncounterPersistence {
|
||||
load(): Encounter | null;
|
||||
save(encounter: Encounter): void;
|
||||
readonly load: () => Encounter | null;
|
||||
readonly save: (encounter: Encounter) => void;
|
||||
}
|
||||
|
||||
export interface UndoRedoPersistence {
|
||||
load(): UndoRedoState;
|
||||
save(state: UndoRedoState): void;
|
||||
readonly load: () => UndoRedoState;
|
||||
readonly save: (state: UndoRedoState) => void;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterPersistence {
|
||||
load(): PlayerCharacter[];
|
||||
save(characters: PlayerCharacter[]): void;
|
||||
readonly load: () => PlayerCharacter[];
|
||||
readonly save: (characters: PlayerCharacter[]) => void;
|
||||
}
|
||||
|
||||
export interface CachedSourceInfo {
|
||||
@@ -31,31 +31,34 @@ export interface CachedSourceInfo {
|
||||
}
|
||||
|
||||
export interface BestiaryCachePort {
|
||||
cacheSource(
|
||||
readonly cacheSource: (
|
||||
system: string,
|
||||
sourceCode: string,
|
||||
displayName: string,
|
||||
creatures: AnyCreature[],
|
||||
): Promise<void>;
|
||||
isSourceCached(system: string, sourceCode: string): Promise<boolean>;
|
||||
getCachedSources(system?: string): Promise<CachedSourceInfo[]>;
|
||||
clearSource(system: string, sourceCode: string): Promise<void>;
|
||||
clearAll(): Promise<void>;
|
||||
loadAllCachedCreatures(): Promise<Map<CreatureId, AnyCreature>>;
|
||||
) => Promise<void>;
|
||||
readonly isSourceCached: (
|
||||
system: string,
|
||||
sourceCode: string,
|
||||
) => Promise<boolean>;
|
||||
readonly getCachedSources: (system?: string) => Promise<CachedSourceInfo[]>;
|
||||
readonly clearSource: (system: string, sourceCode: string) => Promise<void>;
|
||||
readonly clearAll: () => Promise<void>;
|
||||
readonly loadAllCachedCreatures: () => Promise<Map<CreatureId, AnyCreature>>;
|
||||
}
|
||||
|
||||
export interface BestiaryIndexPort {
|
||||
loadIndex(): BestiaryIndex;
|
||||
getAllSourceCodes(): string[];
|
||||
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||
getSourceDisplayName(sourceCode: string): string;
|
||||
readonly loadIndex: () => BestiaryIndex;
|
||||
readonly getAllSourceCodes: () => string[];
|
||||
readonly getDefaultFetchUrl: (sourceCode: string, baseUrl?: string) => string;
|
||||
readonly getSourceDisplayName: (sourceCode: string) => string;
|
||||
}
|
||||
|
||||
export interface Pf2eBestiaryIndexPort {
|
||||
loadIndex(): Pf2eBestiaryIndex;
|
||||
getAllSourceCodes(): string[];
|
||||
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||
getSourceDisplayName(sourceCode: string): string;
|
||||
getCreaturePathsForSource(sourceCode: string): string[];
|
||||
getCreatureNamesByPaths(paths: string[]): Map<string, string>;
|
||||
readonly loadIndex: () => Pf2eBestiaryIndex;
|
||||
readonly getAllSourceCodes: () => string[];
|
||||
readonly getDefaultFetchUrl: (sourceCode: string, baseUrl?: string) => string;
|
||||
readonly getSourceDisplayName: (sourceCode: string) => string;
|
||||
readonly getCreaturePathsForSource: (sourceCode: string) => string[];
|
||||
readonly getCreatureNamesByPaths: (paths: string[]) => Map<string, string>;
|
||||
}
|
||||
|
||||
@@ -373,6 +373,26 @@ describe("CombatantRow", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("popover is not dimmed when the combatant is downed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRow({
|
||||
combatant: {
|
||||
id: combatantId("1"),
|
||||
name: "Goblin",
|
||||
maxHp: 10,
|
||||
currentHp: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await user.click(screen.getByLabelText(CURRENT_HP_REGEX));
|
||||
// The row dims downed combatants with opacity-50, which would cascade
|
||||
// to the popover if it rendered inside the dimmed subtree.
|
||||
const popover = screen
|
||||
.getByRole("button", { name: "Apply damage" })
|
||||
.closest(".opacity-50");
|
||||
expect(popover).toBeNull();
|
||||
});
|
||||
|
||||
it("HP section is absent when maxHp is undefined", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
|
||||
@@ -3,11 +3,41 @@ import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HpAdjustPopover } from "../hp-adjust-popover";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function AnchoredPopover({
|
||||
onAdjust,
|
||||
onSetTempHp,
|
||||
onClose,
|
||||
}: Readonly<{
|
||||
onAdjust: (delta: number) => void;
|
||||
onSetTempHp: (value: number) => void;
|
||||
onClose: () => void;
|
||||
}>) {
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
// The popover opens on click in the app, so its anchor is always mounted
|
||||
// first. Mirror that here — otherwise the anchor ref is still null when the
|
||||
// popover measures its position and it renders hidden.
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => setOpen(true), []);
|
||||
return (
|
||||
<div ref={anchorRef}>
|
||||
{!!open && (
|
||||
<HpAdjustPopover
|
||||
anchorRef={anchorRef}
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPopover(
|
||||
overrides: Partial<{
|
||||
onAdjust: (delta: number) => void;
|
||||
@@ -19,7 +49,7 @@ function renderPopover(
|
||||
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
|
||||
const onClose = overrides.onClose ?? vi.fn();
|
||||
const result = render(
|
||||
<HpAdjustPopover
|
||||
<AnchoredPopover
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={onClose}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRef } from "react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { buildPlayerCharacter } from "../../__tests__/factories/index.js";
|
||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import {
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
} from "../player-character-section.js";
|
||||
|
||||
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
||||
const ADD_PARTY_REGEX = /add party to encounter/i;
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
@@ -33,21 +37,28 @@ beforeAll(() => {
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderSection() {
|
||||
function renderSection(playerCharacters?: PlayerCharacter[]) {
|
||||
const ref = createRef<PlayerCharacterSectionHandle>();
|
||||
const adapters = createTestAdapters({ playerCharacters });
|
||||
const result = render(<PlayerCharacterSection ref={ref} />, {
|
||||
wrapper: AllProviders,
|
||||
wrapper: ({ children }) => (
|
||||
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||
),
|
||||
});
|
||||
return { ...result, ref };
|
||||
}
|
||||
|
||||
function openManagement(ref: { current: PlayerCharacterSectionHandle | null }) {
|
||||
const handle = ref.current;
|
||||
if (!handle) throw new Error("ref not set");
|
||||
act(() => handle.openManagement());
|
||||
}
|
||||
|
||||
describe("PlayerCharacterSection", () => {
|
||||
it("openManagement ref handle opens the management dialog", async () => {
|
||||
const { ref } = renderSection();
|
||||
|
||||
const handle = ref.current;
|
||||
if (!handle) throw new Error("ref not set");
|
||||
act(() => handle.openManagement());
|
||||
openManagement(ref);
|
||||
|
||||
// Management dialog should now be open with its title visible
|
||||
await waitFor(() => {
|
||||
@@ -63,9 +74,7 @@ describe("PlayerCharacterSection", () => {
|
||||
const user = userEvent.setup();
|
||||
const { ref } = renderSection();
|
||||
|
||||
const handle = ref.current;
|
||||
if (!handle) throw new Error("ref not set");
|
||||
act(() => handle.openManagement());
|
||||
openManagement(ref);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
@@ -83,9 +92,7 @@ describe("PlayerCharacterSection", () => {
|
||||
const user = userEvent.setup();
|
||||
const { ref } = renderSection();
|
||||
|
||||
const handle = ref.current;
|
||||
if (!handle) throw new Error("ref not set");
|
||||
act(() => handle.openManagement());
|
||||
openManagement(ref);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
@@ -105,4 +112,25 @@ describe("PlayerCharacterSection", () => {
|
||||
expect(screen.getByText("Aria")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("adding the party puts every character into the encounter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const party = [
|
||||
buildPlayerCharacter({ name: "Thorin" }),
|
||||
buildPlayerCharacter({ name: "Gandalf" }),
|
||||
];
|
||||
const { ref } = renderSection(party);
|
||||
|
||||
openManagement(ref);
|
||||
await user.click(screen.getByRole("button", { name: ADD_PARTY_REGEX }));
|
||||
|
||||
// Dialog closes; reopening shows both characters marked as in the encounter
|
||||
openManagement(ref);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByLabelText("Already in encounter")).toHaveLength(2);
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: ADD_PARTY_REGEX }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { type PlayerCharacter, playerCharacterId } from "@initiative/domain";
|
||||
import {
|
||||
type PlayerCharacter,
|
||||
type PlayerCharacterId,
|
||||
playerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
@@ -11,6 +15,7 @@ afterEach(cleanup);
|
||||
|
||||
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
||||
const LEVEL_REGEX = /^Lv /;
|
||||
const ADD_PARTY_REGEX = /add party to encounter/i;
|
||||
|
||||
import { PlayerManagement } from "../player-management.js";
|
||||
|
||||
@@ -47,6 +52,8 @@ function renderManagement(
|
||||
onEdit: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onCreate: vi.fn(),
|
||||
inEncounterIds: new Set<PlayerCharacterId>(),
|
||||
onAddParty: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return { ...render(<PlayerManagement {...props} />), props };
|
||||
@@ -117,4 +124,52 @@ describe("PlayerManagement", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("party button calls onAddParty", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props } = renderManagement({
|
||||
characters: [PC_WARRIOR, PC_WIZARD],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole("button", { name: ADD_PARTY_REGEX }));
|
||||
expect(props.onAddParty).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("party button stays enabled while some characters are missing", () => {
|
||||
renderManagement({
|
||||
characters: [PC_WARRIOR, PC_WIZARD],
|
||||
inEncounterIds: new Set([PC_WARRIOR.id]),
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: ADD_PARTY_REGEX })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("party button is disabled when every character is in the encounter", () => {
|
||||
renderManagement({
|
||||
characters: [PC_WARRIOR, PC_WIZARD],
|
||||
inEncounterIds: new Set([PC_WARRIOR.id, PC_WIZARD.id]),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: ADD_PARTY_REGEX }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("marks only the characters already in the encounter", () => {
|
||||
renderManagement({
|
||||
characters: [PC_WARRIOR, PC_WIZARD],
|
||||
inEncounterIds: new Set([PC_WIZARD.id]),
|
||||
});
|
||||
|
||||
const markers = screen.getAllByLabelText("Already in encounter");
|
||||
expect(markers).toHaveLength(1);
|
||||
expect(markers[0].closest("div")).toHaveTextContent("Gandalf");
|
||||
});
|
||||
|
||||
it("has no party button in the empty state", () => {
|
||||
renderManagement();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: ADD_PARTY_REGEX }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,8 +63,11 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
const btn5e = screen.getByRole("button", { name: "5e (2014)" });
|
||||
await user.click(btn5e);
|
||||
// After clicking 5e, it should have the active style
|
||||
expect(btn5e.className).toContain("bg-accent");
|
||||
expect(btn5e).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "5.5e (2024)" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("clicking a theme button switches the active theme", async () => {
|
||||
@@ -72,7 +75,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
const darkBtn = screen.getByRole("button", { name: "Dark" });
|
||||
await user.click(darkBtn);
|
||||
expect(darkBtn.className).toContain("bg-accent");
|
||||
expect(darkBtn).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("close button calls onClose", async () => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
import type { Creature, HpVariant } from "@initiative/domain";
|
||||
import { combatantId, creatureId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
|
||||
|
||||
@@ -128,6 +130,19 @@ function renderStatBlock(creature: Creature) {
|
||||
return render(<StatBlock creature={creature} />);
|
||||
}
|
||||
|
||||
/** Owns the variant the way the encounter state does in the real app. */
|
||||
function HpVariantHarness({ creature }: Readonly<{ creature: Creature }>) {
|
||||
const [variant, setVariant] = useState<HpVariant | undefined>(undefined);
|
||||
return (
|
||||
<StatBlock
|
||||
creature={creature}
|
||||
combatantId={combatantId("c-1")}
|
||||
hpVariant={variant}
|
||||
onSetHpVariant={(_id, next) => setVariant(next)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("StatBlock", () => {
|
||||
describe("header", () => {
|
||||
it("renders creature name", () => {
|
||||
@@ -175,6 +190,81 @@ describe("StatBlock", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hit point variant", () => {
|
||||
it("offers no variant buttons while browsing without a combatant", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Max" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the variant buttons when the HP formula is not a dice pool", () => {
|
||||
render(
|
||||
<HpVariantHarness
|
||||
creature={{
|
||||
...GOBLIN,
|
||||
hp: { average: 50, formula: "special" },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Max" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("50")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the printed average until a variant is picked", () => {
|
||||
render(<HpVariantHarness creature={GOBLIN} />);
|
||||
expect(screen.getByText("7")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Avg" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the maximum roll after picking Max", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HpVariantHarness creature={GOBLIN} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Max" }));
|
||||
|
||||
expect(screen.getByText("12")).toBeInTheDocument();
|
||||
expect(screen.queryByText("7")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Max" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the minimum roll after picking Min", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HpVariantHarness creature={GOBLIN} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Min" }));
|
||||
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to the average after picking Avg again", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HpVariantHarness creature={GOBLIN} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Max" }));
|
||||
await user.click(screen.getByRole("button", { name: "Avg" }));
|
||||
|
||||
expect(screen.getByText("7")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps showing the formula for every variant", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<HpVariantHarness creature={GOBLIN} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Min" }));
|
||||
|
||||
expect(screen.getByText("(2d6)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ability scores", () => {
|
||||
it("renders all 6 ability labels", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
|
||||
@@ -202,6 +202,7 @@ function ClickableHp({
|
||||
onSetTempHp: (value: number) => void;
|
||||
}>) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
|
||||
if (maxHp === undefined) {
|
||||
@@ -209,7 +210,7 @@ function ClickableHp({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<div ref={anchorRef} className="relative flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPopoverOpen(true)}
|
||||
@@ -230,6 +231,7 @@ function ClickableHp({
|
||||
)}
|
||||
{!!popoverOpen && (
|
||||
<HpAdjustPopover
|
||||
anchorRef={anchorRef}
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={() => setPopoverOpen(false)}
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import type {
|
||||
CombatantId,
|
||||
Creature,
|
||||
HpRange,
|
||||
HpVariant,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
calculateInitiative,
|
||||
formatInitiativeModifier,
|
||||
hpForVariant,
|
||||
hpRange,
|
||||
} from "@initiative/domain";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import {
|
||||
PropertyLine,
|
||||
SectionDivider,
|
||||
TraitEntry,
|
||||
TraitSection,
|
||||
} from "./stat-block-parts.js";
|
||||
import type { SegmentedOption } from "./ui/segmented-control.js";
|
||||
import { SegmentedControl } from "./ui/segmented-control.js";
|
||||
|
||||
interface DndStatBlockProps {
|
||||
creature: Creature;
|
||||
combatantId?: CombatantId;
|
||||
hpVariant?: HpVariant;
|
||||
onSetHpVariant?: (
|
||||
id: CombatantId,
|
||||
variant: HpVariant | undefined,
|
||||
range: HpRange,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function abilityMod(score: number): string {
|
||||
@@ -19,7 +36,25 @@ function abilityMod(score: number): string {
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
}
|
||||
|
||||
export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||
/** Text color for a max HP that no longer shows the printed average. */
|
||||
function hpVariantColor(variant: HpVariant | undefined): string {
|
||||
if (variant === "max") return "text-blue-400";
|
||||
if (variant === "min") return "text-red-400";
|
||||
return "";
|
||||
}
|
||||
|
||||
const HP_VARIANT_OPTIONS: SegmentedOption<HpVariant | undefined>[] = [
|
||||
{ value: "min", label: "Min" },
|
||||
{ value: undefined, label: "Avg" },
|
||||
{ value: "max", label: "Max" },
|
||||
];
|
||||
|
||||
export function DndStatBlock({
|
||||
creature,
|
||||
combatantId,
|
||||
hpVariant,
|
||||
onSetHpVariant,
|
||||
}: Readonly<DndStatBlockProps>) {
|
||||
const abilities = [
|
||||
{ label: "STR", score: creature.abilities.str },
|
||||
{ label: "DEX", score: creature.abilities.dex },
|
||||
@@ -35,6 +70,17 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||
initiativeProficiency: creature.initiativeProficiency,
|
||||
});
|
||||
|
||||
// Only offer min/max HP when the formula is a real dice pool with a spread.
|
||||
const range = hpRange(creature.hp);
|
||||
const canPickVariant =
|
||||
range !== null &&
|
||||
range.min !== range.max &&
|
||||
combatantId != null &&
|
||||
onSetHpVariant != null;
|
||||
const displayedHp = range
|
||||
? hpForVariant(range, hpVariant)
|
||||
: creature.hp.average;
|
||||
|
||||
return (
|
||||
<div className="space-y-1 text-foreground">
|
||||
{/* Header */}
|
||||
@@ -68,8 +114,22 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Hit Points</span>{" "}
|
||||
{creature.hp.average}{" "}
|
||||
<span className={cn("font-semibold", hpVariantColor(hpVariant))}>
|
||||
{displayedHp}
|
||||
</span>{" "}
|
||||
<span className="text-muted-foreground">({creature.hp.formula})</span>
|
||||
{canPickVariant ? (
|
||||
<SegmentedControl
|
||||
options={HP_VARIANT_OPTIONS}
|
||||
value={hpVariant}
|
||||
onChange={(variant) =>
|
||||
onSetHpVariant(combatantId, variant, range)
|
||||
}
|
||||
size="xs"
|
||||
label="Hit point variant"
|
||||
className="mt-1"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Speed</span> {creature.speed}
|
||||
|
||||
@@ -6,18 +6,21 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||
|
||||
interface HpAdjustPopoverProps {
|
||||
readonly anchorRef: React.RefObject<HTMLElement | null>;
|
||||
readonly onAdjust: (delta: number) => void;
|
||||
readonly onSetTempHp: (value: number) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export function HpAdjustPopover({
|
||||
anchorRef,
|
||||
onAdjust,
|
||||
onSetTempHp,
|
||||
onClose,
|
||||
@@ -29,10 +32,9 @@ export function HpAdjustPopover({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const parent = el.parentElement;
|
||||
if (!parent) return;
|
||||
const trigger = parent.getBoundingClientRect();
|
||||
const anchor = anchorRef.current;
|
||||
if (!el || !anchor) return;
|
||||
const trigger = anchor.getBoundingClientRect();
|
||||
const popover = el.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth;
|
||||
let left = trigger.left;
|
||||
@@ -43,7 +45,7 @@ export function HpAdjustPopover({
|
||||
left = 8;
|
||||
}
|
||||
setPos({ top: trigger.bottom + 4, left });
|
||||
}, []);
|
||||
}, [anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
@@ -82,10 +84,10 @@ export function HpAdjustPopover({
|
||||
[applyDelta, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
|
||||
className="card-glow fixed z-50 rounded-lg border border-border bg-background p-2"
|
||||
style={
|
||||
pos
|
||||
? { top: pos.top, left: pos.left }
|
||||
@@ -144,6 +146,7 @@ export function HpAdjustPopover({
|
||||
<ShieldPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
SectionDivider,
|
||||
TraitSection,
|
||||
} from "./stat-block-parts.js";
|
||||
import type { SegmentedOption } from "./ui/segmented-control.js";
|
||||
import { SegmentedControl } from "./ui/segmented-control.js";
|
||||
|
||||
interface Pf2eStatBlockProps {
|
||||
creature: Pf2eCreature;
|
||||
@@ -52,6 +54,12 @@ function formatMod(mod: number): string {
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
}
|
||||
|
||||
const ADJUSTMENT_OPTIONS: SegmentedOption<"weak" | "elite" | undefined>[] = [
|
||||
{ value: "weak", label: "Weak" },
|
||||
{ value: undefined, label: "Normal" },
|
||||
{ value: "elite", label: "Elite" },
|
||||
];
|
||||
|
||||
/** Returns the text color class for stats affected by weak/elite adjustment. */
|
||||
function adjustmentColor(adjustment: "weak" | "elite" | undefined): string {
|
||||
if (adjustment === "elite") return "text-blue-400";
|
||||
@@ -213,29 +221,16 @@ export function Pf2eStatBlock({
|
||||
{combatantId != null &&
|
||||
onSetAdjustment != null &&
|
||||
baseCreature != null && (
|
||||
<div className="mt-1 flex gap-1">
|
||||
{(["weak", "normal", "elite"] as const).map((opt) => {
|
||||
const value = opt === "normal" ? undefined : opt;
|
||||
const isActive = adjustment === value;
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 font-medium text-xs capitalize",
|
||||
isActive
|
||||
? "bg-accent text-primary-foreground"
|
||||
: "bg-card text-muted-foreground hover:bg-accent/30",
|
||||
)}
|
||||
onClick={() =>
|
||||
<SegmentedControl
|
||||
options={ADJUSTMENT_OPTIONS}
|
||||
value={adjustment}
|
||||
onChange={(value) =>
|
||||
onSetAdjustment(combatantId, value, baseCreature)
|
||||
}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
size="xs"
|
||||
label="Creature adjustment"
|
||||
className="mt-1"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{displayTraits(creature.traits).map((trait) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { type RefObject, useImperativeHandle, useState } from "react";
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import { type RefObject, useImperativeHandle, useMemo, useState } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { CreatePlayerModal } from "./create-player-modal.js";
|
||||
import { PlayerManagement } from "./player-management.js";
|
||||
@@ -15,6 +16,15 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
}) {
|
||||
const { characters, createCharacter, editCharacter, deleteCharacter } =
|
||||
usePlayerCharactersContext();
|
||||
const { encounter, addParty } = useEncounterContext();
|
||||
|
||||
const inEncounterIds = useMemo(() => {
|
||||
const ids = new Set<PlayerCharacterId>();
|
||||
for (const c of encounter.combatants) {
|
||||
if (c.playerCharacterId) ids.add(c.playerCharacterId);
|
||||
}
|
||||
return ids;
|
||||
}, [encounter.combatants]);
|
||||
|
||||
const [managementOpen, setManagementOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -66,6 +76,11 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
setCreateOpen(true);
|
||||
setManagementOpen(false);
|
||||
}}
|
||||
inEncounterIds={inEncounterIds}
|
||||
onAddParty={() => {
|
||||
addParty(characters);
|
||||
setManagementOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { Pencil, Plus, Swords, Trash2 } from "lucide-react";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
@@ -12,8 +12,12 @@ interface PlayerManagementProps {
|
||||
onEdit: (pc: PlayerCharacter) => void;
|
||||
onDelete: (id: PlayerCharacterId) => void;
|
||||
onCreate: () => void;
|
||||
inEncounterIds: ReadonlySet<PlayerCharacterId>;
|
||||
onAddParty: () => void;
|
||||
}
|
||||
|
||||
const IN_ENCOUNTER_LABEL = "Already in encounter";
|
||||
|
||||
export function PlayerManagement({
|
||||
open,
|
||||
onClose,
|
||||
@@ -21,7 +25,13 @@ export function PlayerManagement({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreate,
|
||||
inEncounterIds,
|
||||
onAddParty,
|
||||
}: Readonly<PlayerManagementProps>) {
|
||||
const addableCount = characters.filter(
|
||||
(pc) => !inEncounterIds.has(pc.id),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||
<DialogHeader title="Player Characters" onClose={onClose} />
|
||||
@@ -61,6 +71,16 @@ export function PlayerManagement({
|
||||
Lv {pc.level}
|
||||
</span>
|
||||
)}
|
||||
{inEncounterIds.has(pc.id) && (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={IN_ENCOUNTER_LABEL}
|
||||
title={IN_ENCOUNTER_LABEL}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<Swords size={14} />
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -80,7 +100,19 @@ export function PlayerManagement({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<Button
|
||||
onClick={onAddParty}
|
||||
disabled={addableCount === 0}
|
||||
title={
|
||||
addableCount === 0
|
||||
? "All player characters are already in the encounter"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Swords size={16} />
|
||||
Add party to encounter
|
||||
</Button>
|
||||
<Button onClick={onCreate} variant="ghost">
|
||||
<Plus size={16} />
|
||||
Add
|
||||
|
||||
@@ -2,28 +2,51 @@ import type { RulesEdition } from "@initiative/domain";
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useThemeContext } from "../contexts/theme-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||
import type { SegmentedOption } from "./ui/segmented-control.js";
|
||||
import { SegmentedControl } from "./ui/segmented-control.js";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
|
||||
const EDITION_OPTIONS: SegmentedOption<RulesEdition>[] = [
|
||||
{ value: "5e", label: "5e (2014)" },
|
||||
{ value: "5.5e", label: "5.5e (2024)" },
|
||||
{ value: "pf2e", label: "Pathfinder 2e" },
|
||||
];
|
||||
|
||||
const THEME_OPTIONS: {
|
||||
value: "system" | "light" | "dark";
|
||||
label: string;
|
||||
icon: typeof Sun;
|
||||
}[] = [
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
type ThemePreference = "system" | "light" | "dark";
|
||||
|
||||
const THEME_OPTIONS: SegmentedOption<ThemePreference>[] = [
|
||||
{
|
||||
value: "system",
|
||||
label: (
|
||||
<>
|
||||
<Monitor size={14} />
|
||||
System
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "light",
|
||||
label: (
|
||||
<>
|
||||
<Sun size={14} />
|
||||
Light
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: (
|
||||
<>
|
||||
<Moon size={14} />
|
||||
Dark
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
@@ -39,50 +62,26 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||
Game System
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{EDITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||
edition === opt.value
|
||||
? "bg-accent text-primary-foreground"
|
||||
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setEdition(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
options={EDITION_OPTIONS}
|
||||
value={edition}
|
||||
onChange={setEdition}
|
||||
stretch
|
||||
label="Game System"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||
Theme
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{THEME_OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||
preference === opt.value
|
||||
? "bg-accent text-primary-foreground"
|
||||
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setPreference(opt.value)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
options={THEME_OPTIONS}
|
||||
value={preference}
|
||||
onChange={setPreference}
|
||||
stretch
|
||||
label="Theme"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
CombatantId,
|
||||
Creature,
|
||||
CreatureId,
|
||||
HpRange,
|
||||
HpVariant,
|
||||
Pf2eCreature,
|
||||
} from "@initiative/domain";
|
||||
import { applyPf2eAdjustment } from "@initiative/domain";
|
||||
@@ -225,7 +227,8 @@ function MobileDrawer({
|
||||
function usePanelRole(panelRole: "browse" | "pinned") {
|
||||
const sidePanel = useSidePanelContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
const { encounter, setCreatureAdjustment } = useEncounterContext();
|
||||
const { encounter, setCreatureAdjustment, setHpVariant } =
|
||||
useEncounterContext();
|
||||
|
||||
const creatureId =
|
||||
panelRole === "browse"
|
||||
@@ -245,6 +248,7 @@ function usePanelRole(panelRole: "browse" | "pinned") {
|
||||
creature,
|
||||
combatant,
|
||||
setCreatureAdjustment,
|
||||
setHpVariant,
|
||||
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
||||
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
||||
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
||||
@@ -256,14 +260,23 @@ function usePanelRole(panelRole: "browse" | "pinned") {
|
||||
};
|
||||
}
|
||||
|
||||
function renderStatBlock(
|
||||
creature: AnyCreature,
|
||||
combatant: Combatant | null,
|
||||
interface StatBlockHandlers {
|
||||
setCreatureAdjustment: (
|
||||
id: CombatantId,
|
||||
adj: "weak" | "elite" | undefined,
|
||||
base: Pf2eCreature,
|
||||
) => void,
|
||||
) => void;
|
||||
setHpVariant: (
|
||||
id: CombatantId,
|
||||
variant: HpVariant | undefined,
|
||||
range: HpRange,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function renderStatBlock(
|
||||
creature: AnyCreature,
|
||||
combatant: Combatant | null,
|
||||
handlers: StatBlockHandlers,
|
||||
) {
|
||||
if ("system" in creature && creature.system === "pf2e") {
|
||||
const baseCreature = creature;
|
||||
@@ -276,11 +289,18 @@ function renderStatBlock(
|
||||
adjustment={combatant?.creatureAdjustment}
|
||||
combatantId={combatant?.id}
|
||||
baseCreature={baseCreature}
|
||||
onSetAdjustment={setCreatureAdjustment}
|
||||
onSetAdjustment={handlers.setCreatureAdjustment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <DndStatBlock creature={creature as Creature} />;
|
||||
return (
|
||||
<DndStatBlock
|
||||
creature={creature as Creature}
|
||||
combatantId={combatant?.id}
|
||||
hpVariant={combatant?.hpVariant}
|
||||
onSetHpVariant={handlers.setHpVariant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatBlockPanel({
|
||||
@@ -292,6 +312,7 @@ export function StatBlockPanel({
|
||||
creature,
|
||||
combatant,
|
||||
setCreatureAdjustment,
|
||||
setHpVariant,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onDismiss,
|
||||
@@ -363,7 +384,10 @@ export function StatBlockPanel({
|
||||
}
|
||||
|
||||
if (creature) {
|
||||
return renderStatBlock(creature, combatant, setCreatureAdjustment);
|
||||
return renderStatBlock(creature, combatant, {
|
||||
setCreatureAdjustment,
|
||||
setHpVariant,
|
||||
});
|
||||
}
|
||||
|
||||
if (needsFetch && sourceCode) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
export interface SegmentedOption<T> {
|
||||
readonly value: T;
|
||||
readonly label: ReactNode;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T> {
|
||||
options: readonly SegmentedOption<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
/** "sm" for standalone controls, "xs" for controls inline in a stat block. */
|
||||
size?: "sm" | "xs";
|
||||
/** Segments share the full width of the row instead of hugging their label. */
|
||||
stretch?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of mutually exclusive options where exactly one is active — game
|
||||
* system, theme, PF2e adjustment, D&D hit point variant.
|
||||
*/
|
||||
export function SegmentedControl<T>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
size = "sm",
|
||||
stretch = false,
|
||||
label,
|
||||
className,
|
||||
}: Readonly<SegmentedControlProps<T>>) {
|
||||
return (
|
||||
<fieldset aria-label={label} className={cn("flex gap-1", className)}>
|
||||
{options.map((option) => {
|
||||
const isActive = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors",
|
||||
size === "xs" ? "px-2 py-0.5 text-xs" : "px-3 py-1.5 text-sm",
|
||||
stretch ? "flex-1" : "",
|
||||
isActive
|
||||
? "bg-accent text-primary-foreground"
|
||||
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
||||
)}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { HpRange, HpVariant } from "@initiative/domain";
|
||||
import {
|
||||
combatantId,
|
||||
creatureId,
|
||||
EMPTY_UNDO_REDO_STATE,
|
||||
} from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
||||
|
||||
// An ogre with 3d6 + 6 hit points.
|
||||
const RANGE: HpRange = { min: 9, average: 16, max: 24 };
|
||||
|
||||
function stateWithCreature(
|
||||
maxHp: number,
|
||||
currentHp: number,
|
||||
hpVariant?: HpVariant,
|
||||
): EncounterState {
|
||||
return {
|
||||
encounter: {
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name: "Ogre",
|
||||
maxHp,
|
||||
currentHp,
|
||||
creatureId: creatureId("mm:ogre"),
|
||||
...(hpVariant !== undefined && { hpVariant }),
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
undoRedoState: EMPTY_UNDO_REDO_STATE,
|
||||
events: [],
|
||||
nextId: 1,
|
||||
lastCreatureId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function setVariant(state: EncounterState, variant: HpVariant | undefined) {
|
||||
return encounterReducer(state, {
|
||||
type: "set-hp-variant",
|
||||
id: combatantId("c-1"),
|
||||
variant,
|
||||
range: RANGE,
|
||||
});
|
||||
}
|
||||
|
||||
describe("set-hp-variant", () => {
|
||||
it("raises max HP to the maximum roll and stores the variant", () => {
|
||||
const next = setVariant(stateWithCreature(16, 16), "max");
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(24);
|
||||
expect(c.currentHp).toBe(24);
|
||||
expect(c.hpVariant).toBe("max");
|
||||
});
|
||||
|
||||
it("lowers max HP to the minimum roll", () => {
|
||||
const next = setVariant(stateWithCreature(16, 16), "min");
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(9);
|
||||
expect(c.hpVariant).toBe("min");
|
||||
});
|
||||
|
||||
it("returns to the average and clears the variant", () => {
|
||||
const next = setVariant(stateWithCreature(24, 24, "max"), undefined);
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(16);
|
||||
expect(c.hpVariant).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not rename the combatant", () => {
|
||||
const next = setVariant(stateWithCreature(16, 16), "max");
|
||||
expect(next.encounter.combatants[0].name).toBe("Ogre");
|
||||
});
|
||||
|
||||
it("is undoable", () => {
|
||||
const start = stateWithCreature(16, 16);
|
||||
const next = setVariant(start, "max");
|
||||
const undone = encounterReducer(next, { type: "undo" });
|
||||
expect(undone.encounter.combatants[0].maxHp).toBe(16);
|
||||
expect(undone.encounter.combatants[0].hpVariant).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves state untouched for an unknown combatant", () => {
|
||||
const start = stateWithCreature(16, 16);
|
||||
const next = encounterReducer(start, {
|
||||
type: "set-hp-variant",
|
||||
id: combatantId("c-99"),
|
||||
variant: "max",
|
||||
range: RANGE,
|
||||
});
|
||||
expect(next).toBe(start);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ 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 { buildPlayerCharacter } from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import type { SearchResult } from "../use-bestiary.js";
|
||||
import { useEncounter } from "../use-encounter.js";
|
||||
@@ -256,4 +257,54 @@ describe("useEncounter", () => {
|
||||
expect(combatant.icon).toBe("sword");
|
||||
expect(combatant.playerCharacterId).toBe(playerCharacterId("pc-1"));
|
||||
});
|
||||
|
||||
it("addParty adds every party member", () => {
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
const party = [
|
||||
buildPlayerCharacter({ name: "Aria" }),
|
||||
buildPlayerCharacter({ name: "Borin" }),
|
||||
];
|
||||
|
||||
act(() => result.current.addParty(party));
|
||||
|
||||
const names = result.current.encounter.combatants.map((c) => c.name);
|
||||
expect(names).toEqual(["Aria", "Borin"]);
|
||||
});
|
||||
|
||||
it("addParty skips members already in the encounter", () => {
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
const aria = buildPlayerCharacter({ name: "Aria" });
|
||||
const borin = buildPlayerCharacter({ name: "Borin" });
|
||||
|
||||
act(() => result.current.addFromPlayerCharacter(aria));
|
||||
act(() => result.current.addParty([aria, borin]));
|
||||
|
||||
const names = result.current.encounter.combatants.map((c) => c.name);
|
||||
expect(names).toEqual(["Aria", "Borin"]);
|
||||
});
|
||||
|
||||
it("addParty is undone in a single step", () => {
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
const party = [
|
||||
buildPlayerCharacter({ name: "Aria" }),
|
||||
buildPlayerCharacter({ name: "Borin" }),
|
||||
];
|
||||
|
||||
act(() => result.current.addParty(party));
|
||||
act(() => result.current.undo());
|
||||
|
||||
expect(result.current.encounter.combatants).toEqual([]);
|
||||
expect(result.current.canUndo).toBe(false);
|
||||
});
|
||||
|
||||
it("addParty does nothing when the whole party is already present", () => {
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
const aria = buildPlayerCharacter({ name: "Aria" });
|
||||
|
||||
act(() => result.current.addParty([aria]));
|
||||
act(() => result.current.addParty([aria]));
|
||||
act(() => result.current.undo());
|
||||
|
||||
expect(result.current.encounter.combatants).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
setConditionValueUseCase,
|
||||
setCrUseCase,
|
||||
setHpUseCase,
|
||||
setHpVariantUseCase,
|
||||
setInitiativeUseCase,
|
||||
setSideUseCase,
|
||||
setTempHpUseCase,
|
||||
@@ -30,6 +31,8 @@ import type {
|
||||
DomainError,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
HpRange,
|
||||
HpVariant,
|
||||
PersistentDamageType,
|
||||
Pf2eCreature,
|
||||
PlayerCharacter,
|
||||
@@ -59,6 +62,12 @@ type EncounterAction =
|
||||
| { type: "edit-combatant"; id: CombatantId; newName: string }
|
||||
| { type: "set-initiative"; id: CombatantId; value: number | undefined }
|
||||
| { type: "set-hp"; id: CombatantId; maxHp: number | undefined }
|
||||
| {
|
||||
type: "set-hp-variant";
|
||||
id: CombatantId;
|
||||
variant: HpVariant | undefined;
|
||||
range: HpRange;
|
||||
}
|
||||
| { type: "adjust-hp"; id: CombatantId; delta: number }
|
||||
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
||||
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
||||
@@ -108,6 +117,7 @@ type EncounterAction =
|
||||
baseCreature: Pf2eCreature;
|
||||
}
|
||||
| { type: "add-from-player-character"; pc: PlayerCharacter }
|
||||
| { type: "add-party"; pcs: readonly PlayerCharacter[] }
|
||||
| {
|
||||
type: "import";
|
||||
encounter: Encounter;
|
||||
@@ -277,13 +287,13 @@ function handleAddFromBestiary(
|
||||
};
|
||||
}
|
||||
|
||||
function handleAddFromPlayerCharacter(
|
||||
state: EncounterState,
|
||||
function addOneFromPlayerCharacter(
|
||||
store: EncounterStore,
|
||||
pc: PlayerCharacter,
|
||||
): EncounterState {
|
||||
const { store, getEncounter } = makeStoreFromState(state);
|
||||
nextId: number,
|
||||
): DomainEvent[] | null {
|
||||
const newName = resolveAndRename(store, pc.name);
|
||||
const id = combatantId(`c-${state.nextId + 1}`);
|
||||
const id = combatantId(`c-${nextId + 1}`);
|
||||
const result = addCombatantUseCase(store, id, newName, {
|
||||
maxHp: pc.maxHp,
|
||||
ac: pc.ac > 0 ? pc.ac : undefined,
|
||||
@@ -291,17 +301,58 @@ function handleAddFromPlayerCharacter(
|
||||
icon: pc.icon,
|
||||
playerCharacterId: pc.id,
|
||||
});
|
||||
if (isDomainError(result)) return state;
|
||||
return isDomainError(result) ? null : result;
|
||||
}
|
||||
|
||||
function handleAddFromPlayerCharacter(
|
||||
state: EncounterState,
|
||||
pc: PlayerCharacter,
|
||||
): EncounterState {
|
||||
const { store, getEncounter } = makeStoreFromState(state);
|
||||
const events = addOneFromPlayerCharacter(store, pc, state.nextId);
|
||||
if (!events) return state;
|
||||
return {
|
||||
...state,
|
||||
encounter: getEncounter(),
|
||||
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||
events: [...state.events, ...result],
|
||||
events: [...state.events, ...events],
|
||||
nextId: state.nextId + 1,
|
||||
lastCreatureId: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Adds every party member that is not already in the encounter, as one undo step. */
|
||||
function handleAddParty(
|
||||
state: EncounterState,
|
||||
pcs: readonly PlayerCharacter[],
|
||||
): EncounterState {
|
||||
const present = new Set(
|
||||
state.encounter.combatants.map((c) => c.playerCharacterId),
|
||||
);
|
||||
const missing = pcs.filter((pc) => !present.has(pc.id));
|
||||
if (missing.length === 0) return state;
|
||||
|
||||
const { store, getEncounter } = makeStoreFromState(state);
|
||||
const allEvents: DomainEvent[] = [];
|
||||
let nextId = state.nextId;
|
||||
|
||||
for (const pc of missing) {
|
||||
const events = addOneFromPlayerCharacter(store, pc, nextId);
|
||||
if (!events) return state;
|
||||
allEvents.push(...events);
|
||||
nextId += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
encounter: getEncounter(),
|
||||
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||
events: [...state.events, ...allEvents],
|
||||
nextId,
|
||||
lastCreatureId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function applyNamePrefix(
|
||||
name: string,
|
||||
oldAdj: "weak" | "elite" | undefined,
|
||||
@@ -416,6 +467,8 @@ export function encounterReducer(
|
||||
return handleAddFromBestiary(state, action.entry, action.count);
|
||||
case "add-from-player-character":
|
||||
return handleAddFromPlayerCharacter(state, action.pc);
|
||||
case "add-party":
|
||||
return handleAddParty(state, action.pcs);
|
||||
default:
|
||||
return dispatchEncounterAction(state, action);
|
||||
}
|
||||
@@ -432,6 +485,7 @@ function dispatchEncounterAction(
|
||||
| { type: "edit-combatant" }
|
||||
| { type: "set-initiative" }
|
||||
| { type: "set-hp" }
|
||||
| { type: "set-hp-variant" }
|
||||
| { type: "adjust-hp" }
|
||||
| { type: "set-temp-hp" }
|
||||
| { type: "set-ac" }
|
||||
@@ -472,6 +526,14 @@ function dispatchEncounterAction(
|
||||
case "set-hp":
|
||||
result = setHpUseCase(store, action.id, action.maxHp);
|
||||
break;
|
||||
case "set-hp-variant":
|
||||
result = setHpVariantUseCase(
|
||||
store,
|
||||
action.id,
|
||||
action.variant,
|
||||
action.range,
|
||||
);
|
||||
break;
|
||||
case "adjust-hp":
|
||||
result = adjustHpUseCase(store, action.id, action.delta);
|
||||
break;
|
||||
@@ -706,6 +768,11 @@ export function useEncounter() {
|
||||
}),
|
||||
[],
|
||||
),
|
||||
setHpVariant: useCallback(
|
||||
(id: CombatantId, variant: HpVariant | undefined, range: HpRange) =>
|
||||
dispatch({ type: "set-hp-variant", id, variant, range }),
|
||||
[],
|
||||
),
|
||||
clearEncounter: useCallback(
|
||||
() => dispatch({ type: "clear-encounter" }),
|
||||
[],
|
||||
@@ -730,6 +797,10 @@ export function useEncounter() {
|
||||
dispatch({ type: "add-from-player-character", pc }),
|
||||
[],
|
||||
),
|
||||
addParty: useCallback(
|
||||
(pcs: readonly PlayerCharacter[]) => dispatch({ type: "add-party", pcs }),
|
||||
[],
|
||||
),
|
||||
undo: useCallback(() => dispatch({ type: "undo" }), []),
|
||||
redo: useCallback(() => dispatch({ type: "redo" }), []),
|
||||
setEncounter: useCallback(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"workspaces": {
|
||||
".": {
|
||||
"entry": ["scripts/*.mjs"]
|
||||
|
||||
+7
-3
@@ -3,8 +3,12 @@ pre-commit:
|
||||
jobs:
|
||||
- name: audit
|
||||
run: pnpm audit --audit-level=high
|
||||
# Must go through the npm script, not `pnpm exec knip`: the script sets
|
||||
# KNIP_DISABLE_RAW_TRANSFER=1. Knip 6's default oxc-parser raw-transfer path
|
||||
# allocates a 6 GiB ArrayBuffer, which Linux refuses under heuristic
|
||||
# overcommit on the CI runner (3.7 GB RAM, no swap). See issue #11.
|
||||
- name: knip
|
||||
run: pnpm exec knip
|
||||
run: pnpm knip
|
||||
- name: biome
|
||||
run: pnpm exec biome check .
|
||||
- name: check-ignores
|
||||
@@ -14,7 +18,7 @@ pre-commit:
|
||||
- name: check-props
|
||||
run: node scripts/check-component-props.mjs
|
||||
- name: jscpd
|
||||
run: pnpm exec jscpd
|
||||
run: pnpm jscpd
|
||||
- name: jsinspect
|
||||
run: pnpm jsinspect
|
||||
- name: typecheck-oxlint-test
|
||||
@@ -24,6 +28,6 @@ pre-commit:
|
||||
- name: typecheck
|
||||
run: pnpm exec tsc --build
|
||||
- name: oxlint
|
||||
run: pnpm oxlint -- --deny warnings
|
||||
run: pnpm oxlint
|
||||
- name: test
|
||||
run: pnpm vitest run --reporter=dot --coverage.reporter=text-summary
|
||||
|
||||
+5
-24
@@ -1,31 +1,12 @@
|
||||
{
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"undici": "~7.24.0",
|
||||
"picomatch": ">=4.0.4"
|
||||
},
|
||||
"auditConfig": {
|
||||
"ignoreGhsas": [
|
||||
"GHSA-vmh5-mc38-953g",
|
||||
"GHSA-vxpw-j846-p89q",
|
||||
"GHSA-hm92-r4w5-c3mj"
|
||||
],
|
||||
"_ignoreGhsasNotes": {
|
||||
"_shared": "All three advisories sit in undici, are reached only via jsdom in test runs, and are fixed in undici>=7.28.0. We can't move there because jsdom@29.1.1 reaches into undici 7's private module layout and crashes on the 7.28+ restructure. None of the vulnerable code paths run in our tests (no SOCKS5 proxy, no WebSocket client). Drop these entries when jsdom updates its undici pin.",
|
||||
"GHSA-vmh5-mc38-953g": "SOCKS5 ProxyAgent TLS bypass — unreachable, no SOCKS5 proxy in tests.",
|
||||
"GHSA-vxpw-j846-p89q": "WebSocket client DoS via fragment-count bypass — unreachable, no WS client in tests.",
|
||||
"GHSA-hm92-r4w5-c3mj": "SOCKS5 proxy pool cross-origin reuse — unreachable, no SOCKS5 proxy in tests."
|
||||
}
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.8",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"jscpd": "^4.0.8",
|
||||
"jsinspect-plus": "^3.1.3",
|
||||
"knip": "^5.88.1",
|
||||
"knip": "^6.31.0",
|
||||
"lefthook": "^2.1.4",
|
||||
"oxlint": "^1.56.0",
|
||||
"oxlint-tsgolint": "^0.17.1",
|
||||
@@ -41,13 +22,13 @@
|
||||
"typecheck": "tsc --build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"knip": "knip",
|
||||
"jscpd": "jscpd",
|
||||
"knip": "KNIP_DISABLE_RAW_TRANSFER=1 knip",
|
||||
"jscpd": "jscpd apps/web/src packages/domain/src packages/application/src",
|
||||
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
|
||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny-warnings",
|
||||
"oxlint": "oxlint --tsconfig tsconfig.json --type-aware --deny-warnings",
|
||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||
"check:props": "node scripts/check-component-props.mjs",
|
||||
"check": "pnpm audit --audit-level=high && knip && biome check . && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && jscpd && pnpm jsinspect && tsc --build && oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny warnings && vitest run"
|
||||
"check": "pnpm audit --audit-level=high && pnpm knip && biome check . && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && pnpm jscpd && pnpm jsinspect && tsc --build && pnpm oxlint && vitest run"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export { setAcUseCase } from "./set-ac-use-case.js";
|
||||
export { setConditionValueUseCase } from "./set-condition-value-use-case.js";
|
||||
export { setCrUseCase } from "./set-cr-use-case.js";
|
||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||
export { setHpVariantUseCase } from "./set-hp-variant-use-case.js";
|
||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||
export { setSideUseCase } from "./set-side-use-case.js";
|
||||
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
type HpRange,
|
||||
type HpVariant,
|
||||
setHpVariant,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function setHpVariantUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
variant: HpVariant | undefined,
|
||||
range: HpRange,
|
||||
): DomainEvent[] | DomainError {
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
setHpVariant(encounter, combatantId, variant, range),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkGates } from "../../../../scripts/check-gates.mjs";
|
||||
|
||||
describe("quality gates", () => {
|
||||
it("fail on violations instead of silently passing", () => {
|
||||
const violations = checkGates();
|
||||
if (violations.length > 0) {
|
||||
throw new Error(
|
||||
`Gate integrity violations:\n ${violations.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hpForVariant, hpRange } from "../hp-range.js";
|
||||
|
||||
describe("hpRange", () => {
|
||||
it("derives min and max from a formula with a positive modifier", () => {
|
||||
const range = hpRange({ average: 16, formula: "3d6 + 6" });
|
||||
expect(range).toEqual({ min: 9, average: 16, max: 24 });
|
||||
});
|
||||
|
||||
it("derives min and max from a formula without a modifier", () => {
|
||||
const range = hpRange({ average: 40, formula: "9d8" });
|
||||
expect(range).toEqual({ min: 9, average: 40, max: 72 });
|
||||
});
|
||||
|
||||
it("subtracts a negative modifier", () => {
|
||||
const range = hpRange({ average: 45, formula: "10d8 - 5" });
|
||||
expect(range).toEqual({ min: 5, average: 45, max: 75 });
|
||||
});
|
||||
|
||||
it("treats an en dash as a minus sign", () => {
|
||||
// Bundled bestiary data contains e.g. "2d6 – 2" (U+2013).
|
||||
const range = hpRange({ average: 5, formula: "2d6 – 2" });
|
||||
expect(range).toEqual({ min: 1, average: 5, max: 10 });
|
||||
});
|
||||
|
||||
it("never drops below 1 HP when the modifier exceeds the dice", () => {
|
||||
const range = hpRange({ average: 1, formula: "1d4 - 10" });
|
||||
expect(range?.min).toBe(1);
|
||||
expect(range?.max).toBe(1);
|
||||
});
|
||||
|
||||
it("handles large pools without whitespace", () => {
|
||||
expect(hpRange({ average: 256, formula: "19d12+133" })).toEqual({
|
||||
min: 152,
|
||||
average: 256,
|
||||
max: 361,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for a missing formula", () => {
|
||||
expect(hpRange({ average: 7, formula: "" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for prose instead of a dice pool", () => {
|
||||
expect(
|
||||
hpRange({ average: 0, formula: "equal to the summoner's" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a flat number", () => {
|
||||
expect(hpRange({ average: 50, formula: "50" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a pool with zero dice or zero sides", () => {
|
||||
expect(hpRange({ average: 0, formula: "0d6" })).toBeNull();
|
||||
expect(hpRange({ average: 0, formula: "2d0" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for compound formulas it cannot reason about", () => {
|
||||
expect(hpRange({ average: 20, formula: "2d6 + 2d8" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hpForVariant", () => {
|
||||
const range = { min: 9, average: 16, max: 24 };
|
||||
|
||||
it("returns the average when no variant is set", () => {
|
||||
expect(hpForVariant(range, undefined)).toBe(16);
|
||||
});
|
||||
|
||||
it("returns the minimum for the min variant", () => {
|
||||
expect(hpForVariant(range, "min")).toBe(9);
|
||||
});
|
||||
|
||||
it("returns the maximum for the max variant", () => {
|
||||
expect(hpForVariant(range, "max")).toBe(24);
|
||||
});
|
||||
});
|
||||
@@ -301,6 +301,34 @@ describe("rehydrateCombatant", () => {
|
||||
expect(result?.side).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves valid hpVariant field", () => {
|
||||
for (const hpVariant of ["min", "max"]) {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
hpVariant,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.hpVariant).toBe(hpVariant);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops invalid hpVariant field", () => {
|
||||
for (const hpVariant of ["average", "", 42, null, true]) {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
hpVariant,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.hpVariant).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("combatant without hpVariant rehydrates as before", () => {
|
||||
const result = rehydrateCombatant(minimalCombatant());
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.hpVariant).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves valid persistent damage entries", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HpRange, HpVariant } from "../hp-range.js";
|
||||
import { setHpVariant } from "../set-hp-variant.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
// A 3d6 + 6 creature: 9 / 16 / 24.
|
||||
const RANGE: HpRange = { min: 9, average: 16, max: 24 };
|
||||
|
||||
function makeCombatant(opts?: Partial<Combatant>): Combatant {
|
||||
return {
|
||||
id: combatantId("c-1"),
|
||||
name: "Ogre",
|
||||
maxHp: 16,
|
||||
currentHp: 16,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
function enc(combatants: Combatant[]): Encounter {
|
||||
return { combatants, activeIndex: 0, roundNumber: 1 };
|
||||
}
|
||||
|
||||
function apply(
|
||||
encounter: Encounter,
|
||||
variant: HpVariant | undefined,
|
||||
range: HpRange = RANGE,
|
||||
) {
|
||||
const result = setHpVariant(encounter, combatantId("c-1"), variant, range);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("setHpVariant", () => {
|
||||
describe("acceptance scenarios", () => {
|
||||
it("average → max raises maxHp and currentHp to the maximum roll", () => {
|
||||
const { encounter } = apply(enc([makeCombatant()]), "max");
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(24);
|
||||
expect(c.currentHp).toBe(24);
|
||||
expect(c.hpVariant).toBe("max");
|
||||
});
|
||||
|
||||
it("average → min lowers maxHp and currentHp to the minimum roll", () => {
|
||||
const { encounter } = apply(enc([makeCombatant()]), "min");
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(9);
|
||||
expect(c.currentHp).toBe(9);
|
||||
expect(c.hpVariant).toBe("min");
|
||||
});
|
||||
|
||||
it("max → average restores the printed average", () => {
|
||||
const start = enc([
|
||||
makeCombatant({ maxHp: 24, currentHp: 24, hpVariant: "max" }),
|
||||
]);
|
||||
const { encounter } = apply(start, undefined);
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(16);
|
||||
expect(c.currentHp).toBe(16);
|
||||
expect(c.hpVariant).toBeUndefined();
|
||||
});
|
||||
|
||||
it("min → max spans the full range in one step", () => {
|
||||
const start = enc([
|
||||
makeCombatant({ maxHp: 9, currentHp: 9, hpVariant: "min" }),
|
||||
]);
|
||||
const { encounter } = apply(start, "max");
|
||||
expect(encounter.combatants[0].maxHp).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("damage already taken", () => {
|
||||
it("keeps the amount of damage taken when raising max HP", () => {
|
||||
// 5 damage taken: 11/16 → 19/24
|
||||
const start = enc([makeCombatant({ maxHp: 16, currentHp: 11 })]);
|
||||
const { encounter } = apply(start, "max");
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(24);
|
||||
expect(c.currentHp).toBe(19);
|
||||
});
|
||||
|
||||
it("floors currentHp at 0 when lowering max HP past the damage taken", () => {
|
||||
const start = enc([makeCombatant({ maxHp: 16, currentHp: 3 })]);
|
||||
const { encounter } = apply(start, "min");
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(9);
|
||||
expect(c.currentHp).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps currentHp to the new maxHp", () => {
|
||||
const start = enc([makeCombatant({ maxHp: 100, currentHp: 100 })]);
|
||||
const { encounter } = apply(start, "min");
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(93);
|
||||
expect(c.currentHp).toBe(93);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("preserves a manual max HP edit as an offset", () => {
|
||||
// User bumped the ogre to 20 HP; max is 8 above average.
|
||||
const start = enc([makeCombatant({ maxHp: 20, currentHp: 20 })]);
|
||||
const { encounter } = apply(start, "max");
|
||||
expect(encounter.combatants[0].maxHp).toBe(28);
|
||||
});
|
||||
|
||||
it("never lets maxHp drop below 1", () => {
|
||||
const start = enc([makeCombatant({ maxHp: 2, currentHp: 2 })]);
|
||||
const { encounter } = apply(start, "min");
|
||||
expect(encounter.combatants[0].maxHp).toBe(1);
|
||||
});
|
||||
|
||||
it("leaves a combatant without HP untouched but records the variant", () => {
|
||||
const start = enc([
|
||||
makeCombatant({ maxHp: undefined, currentHp: undefined }),
|
||||
]);
|
||||
const { encounter } = apply(start, "max");
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBeUndefined();
|
||||
expect(c.currentHp).toBeUndefined();
|
||||
expect(c.hpVariant).toBe("max");
|
||||
});
|
||||
|
||||
it("is a no-op when the variant is already active", () => {
|
||||
const start = enc([
|
||||
makeCombatant({ maxHp: 24, currentHp: 20, hpVariant: "max" }),
|
||||
]);
|
||||
const { encounter, events } = apply(start, "max");
|
||||
expect(encounter.combatants[0].currentHp).toBe(20);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns a domain error for an unknown combatant", () => {
|
||||
const result = setHpVariant(
|
||||
enc([makeCombatant()]),
|
||||
combatantId("c-99"),
|
||||
"max",
|
||||
RANGE,
|
||||
);
|
||||
expectDomainError(result, "combatant-not-found");
|
||||
});
|
||||
});
|
||||
|
||||
it("emits an HpVariantSet event carrying the HP change", () => {
|
||||
const { events } = apply(enc([makeCombatant()]), "max");
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "HpVariantSet",
|
||||
combatantId: combatantId("c-1"),
|
||||
variant: "max",
|
||||
previousMaxHp: 16,
|
||||
newMaxHp: 24,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import type { CreatureId } from "./creature-types.js";
|
||||
import type { PersistentDamageType } from "./persistent-damage.js";
|
||||
import type { PersistentDamageType } from "./persistent-damage-types.js";
|
||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||
import type { CombatantId } from "./types.js";
|
||||
|
||||
@@ -152,6 +152,14 @@ export interface CreatureAdjustmentSet {
|
||||
readonly adjustment: "weak" | "elite" | undefined;
|
||||
}
|
||||
|
||||
export interface HpVariantSet {
|
||||
readonly type: "HpVariantSet";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly variant: "min" | "max" | undefined;
|
||||
readonly previousMaxHp: number | undefined;
|
||||
readonly newMaxHp: number | undefined;
|
||||
}
|
||||
|
||||
export interface EncounterCleared {
|
||||
readonly type: "EncounterCleared";
|
||||
readonly combatantCount: number;
|
||||
@@ -198,6 +206,7 @@ export type DomainEvent =
|
||||
| PersistentDamageAdded
|
||||
| PersistentDamageRemoved
|
||||
| CreatureAdjustmentSet
|
||||
| HpVariantSet
|
||||
| EncounterCleared
|
||||
| PlayerCharacterCreated
|
||||
| PlayerCharacterUpdated
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Which end of a creature's Hit Dice range its max HP is taken from. */
|
||||
export type HpVariant = "min" | "max";
|
||||
|
||||
export const VALID_HP_VARIANTS: ReadonlySet<string> = new Set(["min", "max"]);
|
||||
|
||||
export interface HpRange {
|
||||
/** Every Hit Die rolls a 1 (never below 1 HP). */
|
||||
readonly min: number;
|
||||
/** The statblock's printed average. */
|
||||
readonly average: number;
|
||||
/** Every Hit Die rolls its maximum. */
|
||||
readonly max: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a plain Hit Dice pool: "9d8", "3d6 + 6", "2d6 - 2".
|
||||
* Bestiary data uses several dash characters, so hyphen, minus sign, en dash
|
||||
* and em dash all count as a negative modifier.
|
||||
*/
|
||||
const DICE_FORMULA_REGEX =
|
||||
/^\s*(\d+)\s*[dD]\s*(\d+)\s*(?:([-+−–—])\s*(\d+))?\s*$/;
|
||||
|
||||
/**
|
||||
* Derives the min/average/max HP of a creature from its Hit Dice formula.
|
||||
* Returns null when the formula is not a plain dice pool — some sources carry
|
||||
* prose (`hp.special`) or an empty string instead.
|
||||
*/
|
||||
export function hpRange(
|
||||
hp: Readonly<{ average: number; formula: string }>,
|
||||
): HpRange | null {
|
||||
const match = DICE_FORMULA_REGEX.exec(hp.formula);
|
||||
if (!match) return null;
|
||||
|
||||
const count = Number(match[1]);
|
||||
const sides = Number(match[2]);
|
||||
if (count < 1 || sides < 1) return null;
|
||||
|
||||
const modifier =
|
||||
match[4] === undefined
|
||||
? 0
|
||||
: (match[3] === "+" ? 1 : -1) * Number.parseInt(match[4], 10);
|
||||
|
||||
return {
|
||||
min: Math.max(1, count + modifier),
|
||||
average: hp.average,
|
||||
max: Math.max(1, count * sides + modifier),
|
||||
};
|
||||
}
|
||||
|
||||
/** The HP value a variant selects; undefined means the printed average. */
|
||||
export function hpForVariant(
|
||||
range: HpRange,
|
||||
variant: HpVariant | undefined,
|
||||
): number {
|
||||
if (variant === "min") return range.min;
|
||||
if (variant === "max") return range.max;
|
||||
return range.average;
|
||||
}
|
||||
@@ -82,6 +82,7 @@ export type {
|
||||
CurrentHpAdjusted,
|
||||
DomainEvent,
|
||||
EncounterCleared,
|
||||
HpVariantSet,
|
||||
InitiativeSet,
|
||||
MaxHpSet,
|
||||
PersistentDamageAdded,
|
||||
@@ -97,6 +98,13 @@ export type {
|
||||
TurnRetreated,
|
||||
} from "./events.js";
|
||||
export type { ExportBundle } from "./export-bundle.js";
|
||||
export {
|
||||
type HpRange,
|
||||
type HpVariant,
|
||||
hpForVariant,
|
||||
hpRange,
|
||||
VALID_HP_VARIANTS,
|
||||
} from "./hp-range.js";
|
||||
export { deriveHpStatus, type HpStatus } from "./hp-status.js";
|
||||
export {
|
||||
calculateInitiative,
|
||||
@@ -153,6 +161,10 @@ export type { RulesEdition } from "./rules-edition.js";
|
||||
export { type SetAcSuccess, setAc } from "./set-ac.js";
|
||||
export { type SetCrSuccess, setCr } from "./set-cr.js";
|
||||
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
||||
export {
|
||||
type SetHpVariantSuccess,
|
||||
setHpVariant,
|
||||
} from "./set-hp-variant.js";
|
||||
export {
|
||||
type SetInitiativeSuccess,
|
||||
setInitiative,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
export const PERSISTENT_DAMAGE_TYPES = [
|
||||
"fire",
|
||||
"bleed",
|
||||
"acid",
|
||||
"cold",
|
||||
"electricity",
|
||||
"poison",
|
||||
"mental",
|
||||
"force",
|
||||
"void",
|
||||
"spirit",
|
||||
"vitality",
|
||||
"piercing",
|
||||
] as const;
|
||||
|
||||
export type PersistentDamageType = (typeof PERSISTENT_DAMAGE_TYPES)[number];
|
||||
|
||||
export const VALID_PERSISTENT_DAMAGE_TYPES: ReadonlySet<string> = new Set(
|
||||
PERSISTENT_DAMAGE_TYPES,
|
||||
);
|
||||
|
||||
export interface PersistentDamageEntry {
|
||||
readonly type: PersistentDamageType;
|
||||
readonly formula: string;
|
||||
}
|
||||
|
||||
export interface PersistentDamageDefinition {
|
||||
readonly type: PersistentDamageType;
|
||||
readonly label: string;
|
||||
readonly iconName: string;
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
export const PERSISTENT_DAMAGE_DEFINITIONS: readonly PersistentDamageDefinition[] =
|
||||
[
|
||||
{ type: "fire", label: "Fire", iconName: "Flame", color: "orange" },
|
||||
{ type: "bleed", label: "Bleed", iconName: "Droplets", color: "red" },
|
||||
{
|
||||
type: "acid",
|
||||
label: "Acid",
|
||||
iconName: "FlaskConical",
|
||||
color: "lime",
|
||||
},
|
||||
{ type: "cold", label: "Cold", iconName: "Snowflake", color: "sky" },
|
||||
{
|
||||
type: "electricity",
|
||||
label: "Electricity",
|
||||
iconName: "Zap",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
type: "poison",
|
||||
label: "Poison",
|
||||
iconName: "Droplet",
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
type: "mental",
|
||||
label: "Mental",
|
||||
iconName: "BrainCog",
|
||||
color: "pink",
|
||||
},
|
||||
{ type: "force", label: "Force", iconName: "Orbit", color: "indigo" },
|
||||
{ type: "void", label: "Void", iconName: "Eclipse", color: "purple" },
|
||||
{ type: "spirit", label: "Spirit", iconName: "Wind", color: "neutral" },
|
||||
{
|
||||
type: "vitality",
|
||||
label: "Vitality",
|
||||
iconName: "Sparkle",
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
type: "piercing",
|
||||
label: "Piercing",
|
||||
iconName: "Sword",
|
||||
color: "neutral",
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import {
|
||||
PERSISTENT_DAMAGE_DEFINITIONS,
|
||||
type PersistentDamageEntry,
|
||||
type PersistentDamageType,
|
||||
VALID_PERSISTENT_DAMAGE_TYPES,
|
||||
} from "./persistent-damage-types.js";
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
@@ -7,84 +13,14 @@ import {
|
||||
isDomainError,
|
||||
} from "./types.js";
|
||||
|
||||
export const PERSISTENT_DAMAGE_TYPES = [
|
||||
"fire",
|
||||
"bleed",
|
||||
"acid",
|
||||
"cold",
|
||||
"electricity",
|
||||
"poison",
|
||||
"mental",
|
||||
"force",
|
||||
"void",
|
||||
"spirit",
|
||||
"vitality",
|
||||
"piercing",
|
||||
] as const;
|
||||
|
||||
export type PersistentDamageType = (typeof PERSISTENT_DAMAGE_TYPES)[number];
|
||||
|
||||
export const VALID_PERSISTENT_DAMAGE_TYPES: ReadonlySet<string> = new Set(
|
||||
export {
|
||||
PERSISTENT_DAMAGE_DEFINITIONS,
|
||||
PERSISTENT_DAMAGE_TYPES,
|
||||
);
|
||||
|
||||
export interface PersistentDamageEntry {
|
||||
readonly type: PersistentDamageType;
|
||||
readonly formula: string;
|
||||
}
|
||||
|
||||
export interface PersistentDamageDefinition {
|
||||
readonly type: PersistentDamageType;
|
||||
readonly label: string;
|
||||
readonly iconName: string;
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
export const PERSISTENT_DAMAGE_DEFINITIONS: readonly PersistentDamageDefinition[] =
|
||||
[
|
||||
{ type: "fire", label: "Fire", iconName: "Flame", color: "orange" },
|
||||
{ type: "bleed", label: "Bleed", iconName: "Droplets", color: "red" },
|
||||
{
|
||||
type: "acid",
|
||||
label: "Acid",
|
||||
iconName: "FlaskConical",
|
||||
color: "lime",
|
||||
},
|
||||
{ type: "cold", label: "Cold", iconName: "Snowflake", color: "sky" },
|
||||
{
|
||||
type: "electricity",
|
||||
label: "Electricity",
|
||||
iconName: "Zap",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
type: "poison",
|
||||
label: "Poison",
|
||||
iconName: "Droplet",
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
type: "mental",
|
||||
label: "Mental",
|
||||
iconName: "BrainCog",
|
||||
color: "pink",
|
||||
},
|
||||
{ type: "force", label: "Force", iconName: "Orbit", color: "indigo" },
|
||||
{ type: "void", label: "Void", iconName: "Eclipse", color: "purple" },
|
||||
{ type: "spirit", label: "Spirit", iconName: "Wind", color: "neutral" },
|
||||
{
|
||||
type: "vitality",
|
||||
label: "Vitality",
|
||||
iconName: "Sparkle",
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
type: "piercing",
|
||||
label: "Piercing",
|
||||
iconName: "Sword",
|
||||
color: "neutral",
|
||||
},
|
||||
];
|
||||
type PersistentDamageDefinition,
|
||||
type PersistentDamageEntry,
|
||||
type PersistentDamageType,
|
||||
VALID_PERSISTENT_DAMAGE_TYPES,
|
||||
} from "./persistent-damage-types.js";
|
||||
|
||||
export interface PersistentDamageSuccess {
|
||||
readonly encounter: Encounter;
|
||||
|
||||
@@ -2,8 +2,10 @@ import type { ConditionEntry, ConditionId } from "./conditions.js";
|
||||
import { VALID_CONDITION_IDS } from "./conditions.js";
|
||||
import { creatureId } from "./creature-types.js";
|
||||
import { VALID_CR_VALUES } from "./encounter-difficulty.js";
|
||||
import type { PersistentDamageEntry } from "./persistent-damage.js";
|
||||
import { VALID_PERSISTENT_DAMAGE_TYPES } from "./persistent-damage.js";
|
||||
import type { HpVariant } from "./hp-range.js";
|
||||
import { VALID_HP_VARIANTS } from "./hp-range.js";
|
||||
import type { PersistentDamageEntry } from "./persistent-damage-types.js";
|
||||
import { VALID_PERSISTENT_DAMAGE_TYPES } from "./persistent-damage-types.js";
|
||||
import {
|
||||
playerCharacterId,
|
||||
VALID_PLAYER_COLORS,
|
||||
@@ -144,6 +146,9 @@ function parseOptionalFields(entry: Record<string, unknown>) {
|
||||
entry.creatureAdjustment,
|
||||
VALID_ADJUSTMENTS,
|
||||
) as "weak" | "elite" | undefined,
|
||||
hpVariant: validateSetMember(entry.hpVariant, VALID_HP_VARIANTS) as
|
||||
| HpVariant
|
||||
| undefined,
|
||||
cr: validateCr(entry.cr),
|
||||
side: validateSide(entry.side),
|
||||
color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import { type HpRange, type HpVariant, hpForVariant } from "./hp-range.js";
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type Encounter,
|
||||
findCombatant,
|
||||
isDomainError,
|
||||
} from "./types.js";
|
||||
|
||||
export interface SetHpVariantSuccess {
|
||||
readonly encounter: Encounter;
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches a combatant's max HP between the minimum, average and maximum roll
|
||||
* of its Hit Dice.
|
||||
*
|
||||
* The shift is applied as a delta so manual HP edits and damage already taken
|
||||
* survive the switch: a creature missing 5 HP still misses 5 HP afterwards.
|
||||
*/
|
||||
export function setHpVariant(
|
||||
encounter: Encounter,
|
||||
combatantId: CombatantId,
|
||||
variant: HpVariant | undefined,
|
||||
range: HpRange,
|
||||
): SetHpVariantSuccess | DomainError {
|
||||
const found = findCombatant(encounter, combatantId);
|
||||
if (isDomainError(found)) return found;
|
||||
|
||||
const { combatant } = found;
|
||||
if (combatant.hpVariant === variant) {
|
||||
return { encounter, events: [] };
|
||||
}
|
||||
|
||||
const delta =
|
||||
hpForVariant(range, variant) - hpForVariant(range, combatant.hpVariant);
|
||||
|
||||
const newMaxHp =
|
||||
combatant.maxHp === undefined
|
||||
? undefined
|
||||
: Math.max(1, combatant.maxHp + delta);
|
||||
const newCurrentHp =
|
||||
combatant.currentHp === undefined || newMaxHp === undefined
|
||||
? combatant.currentHp
|
||||
: Math.max(0, Math.min(combatant.currentHp + delta, newMaxHp));
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
...encounter,
|
||||
combatants: encounter.combatants.map((c) =>
|
||||
c.id === combatantId
|
||||
? {
|
||||
...c,
|
||||
maxHp: newMaxHp,
|
||||
currentHp: newCurrentHp,
|
||||
hpVariant: variant,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: "HpVariantSet",
|
||||
combatantId,
|
||||
variant,
|
||||
previousMaxHp: combatant.maxHp,
|
||||
newMaxHp,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,8 @@ export function combatantId(id: string): CombatantId {
|
||||
|
||||
import type { ConditionEntry } from "./conditions.js";
|
||||
import type { CreatureId } from "./creature-types.js";
|
||||
import type { PersistentDamageEntry } from "./persistent-damage.js";
|
||||
import type { HpVariant } from "./hp-range.js";
|
||||
import type { PersistentDamageEntry } from "./persistent-damage-types.js";
|
||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||
|
||||
export interface Combatant {
|
||||
@@ -23,6 +24,7 @@ export interface Combatant {
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly creatureId?: CreatureId;
|
||||
readonly creatureAdjustment?: "weak" | "elite";
|
||||
readonly hpVariant?: HpVariant;
|
||||
readonly cr?: string;
|
||||
readonly side?: "party" | "enemy";
|
||||
readonly color?: string;
|
||||
|
||||
Generated
+495
-252
File diff suppressed because it is too large
Load Diff
@@ -4,3 +4,8 @@ packages:
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- lefthook
|
||||
|
||||
# pnpm >= 10.32 reads overrides/auditConfig from here; the "pnpm" field in
|
||||
# package.json is no longer honoured.
|
||||
overrides:
|
||||
picomatch: ">=4.0.4"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
|
||||
const RUN_LINE_RE = /^\s*run:\s*(.+?)\s*$/;
|
||||
|
||||
/** @param {string} path */
|
||||
const read = (path) => readFileSync(join(ROOT, path), "utf-8");
|
||||
|
||||
/**
|
||||
* Gates must invoke `pnpm oxlint` with no extra arguments: pnpm forwards them
|
||||
* after `--`, where oxlint reads them as file paths and lints nothing.
|
||||
* @param {Record<string, string>} scripts
|
||||
*/
|
||||
function findGatesWithOwnFlags(scripts) {
|
||||
const commands = Object.entries(scripts)
|
||||
.filter(([name]) => name !== "oxlint")
|
||||
.flatMap(([name, body]) =>
|
||||
body.split("&&").map((c) => [`scripts.${name}`, c.trim()]),
|
||||
);
|
||||
for (const line of read("lefthook.yml").split("\n")) {
|
||||
const command = RUN_LINE_RE.exec(line)?.[1];
|
||||
if (command) commands.push(["lefthook.yml", command]);
|
||||
}
|
||||
return commands
|
||||
.filter(([, c]) => c.includes("oxlint") && c !== "pnpm oxlint")
|
||||
.map(([source, c]) => `${source} runs "${c}" instead of "pnpm oxlint"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the configured command with one rule forced to warn. A working gate
|
||||
* exits non-zero; exiting 0 means it lints nothing, or does not fail on
|
||||
* warnings.
|
||||
* @param {string} oxlintScript
|
||||
*/
|
||||
function failsOnWarnings(oxlintScript) {
|
||||
const args = [...oxlintScript.split(" ").slice(1), "-W", "no-console"];
|
||||
try {
|
||||
execFileSync(join(ROOT, "node_modules/.bin/oxlint"), args, {
|
||||
cwd: ROOT,
|
||||
stdio: "ignore",
|
||||
});
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports ways a quality gate could pass without checking anything, which is
|
||||
* indistinguishable from a clean run.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function checkGates() {
|
||||
/** @type {Record<string, string>} */
|
||||
const scripts = JSON.parse(read("package.json")).scripts;
|
||||
const violations = findGatesWithOwnFlags(scripts);
|
||||
|
||||
// Vitest defaults to failing when no test file matches. Setting this puts
|
||||
// the test gate back to exiting 0 if the include globs ever stop matching.
|
||||
if (read("vitest.config.ts").includes("passWithNoTests")) {
|
||||
violations.push("vitest.config.ts sets passWithNoTests");
|
||||
}
|
||||
|
||||
// jscpd's pattern is a single glob string. An array matches no files, and
|
||||
// the run then reports success having read nothing.
|
||||
if (Array.isArray(JSON.parse(read(".jscpd.json")).pattern)) {
|
||||
violations.push(".jscpd.json sets pattern to an array, not a glob string");
|
||||
}
|
||||
|
||||
// The canary rule below is not type-aware, so the probe alone cannot
|
||||
// detect type-aware rules being switched off.
|
||||
if (!scripts.oxlint.includes("--type-aware")) {
|
||||
violations.push("scripts.oxlint is missing --type-aware");
|
||||
}
|
||||
if (!failsOnWarnings(scripts.oxlint)) {
|
||||
violations.push(
|
||||
`scripts.oxlint exits 0 despite no-console violations: "${scripts.oxlint}"`,
|
||||
);
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
@@ -63,7 +63,7 @@ function checkFile(file, forbidden) {
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(IMPORT_RE);
|
||||
const match = IMPORT_RE.exec(lines[i]);
|
||||
if (!match) continue;
|
||||
|
||||
const importPath = match[1] || match[2];
|
||||
|
||||
@@ -23,7 +23,7 @@ for (const file of findFiles()) {
|
||||
const lines = readFileSync(file, "utf-8").split("\n");
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = lines[i].match(IGNORE_PATTERN);
|
||||
const match = IGNORE_PATTERN.exec(lines[i]);
|
||||
if (!match) continue;
|
||||
|
||||
count++;
|
||||
|
||||
@@ -108,6 +108,7 @@ const files = readdirSync(BESTIARY_DIR).filter(
|
||||
);
|
||||
|
||||
const creatures = [];
|
||||
/** @type {Set<string>} */
|
||||
const unmappedSources = new Set();
|
||||
|
||||
for (const file of files.sort()) {
|
||||
|
||||
@@ -118,6 +118,11 @@ As a DM running a PF2e encounter, I want to toggle a weak or elite adjustment on
|
||||
|
||||
When viewing a PF2e creature's stat block, a Weak/Normal/Elite toggle appears in the header. Selecting "Elite" or "Weak" applies the standard PF2e adjustments: ±2 to AC, saves, Perception, attack rolls, and strike damage; HP adjusted by the standard level bracket table; level shifted. The combatant's stored HP and AC update accordingly (see `specs/003-combatant-state/spec.md`, FR-113–FR-116), and its name gains a prefix (see `specs/001-combatant-management/spec.md`, FR-041–FR-042). The toggle defaults to "Normal" and is not shown for D&D creatures. A visual indicator (the same icon used in the toggle) appears next to the creature name in the header.
|
||||
|
||||
**US-D8 — Set a D&D Combatant to Minimum or Maximum Hit Points (P2)**
|
||||
As a DM running a D&D encounter, I want to switch a bestiary-linked combatant between the minimum, average and maximum result of its Hit Dice so I can make a single monster tougher or flimsier without hand-editing HP.
|
||||
|
||||
When viewing a D&D creature's stat block for a combatant, a Min/Avg/Max toggle appears under the Hit Points line. The stat block shows the printed average by default; selecting "Min" or "Max" shows the value the Hit Dice would produce if every die rolled its lowest or highest face (e.g., `3d6 + 6` → 9 or 24) and updates the combatant's stored max HP by the same delta. This is a convenience tool, not a rules mechanic — no other stat changes and the combatant is not renamed.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **FR-016**: The system MUST display a stat block panel with full creature information when a creature is selected.
|
||||
@@ -151,6 +156,12 @@ When viewing a PF2e creature's stat block, a Weak/Normal/Elite toggle appears in
|
||||
- **FR-106**: Toggling the adjustment MUST update the combatant's name with the appropriate prefix — "Weak" or "Elite" — or remove the prefix when returning to "Normal" (see `specs/001-combatant-management/spec.md`, FR-041–FR-042).
|
||||
- **FR-107**: The stat block header MUST display a visual indicator (the same icon used in the toggle) next to the creature name when the creature has a weak or elite adjustment.
|
||||
- **FR-108**: The adjustment MUST be stored on the combatant as a `creatureAdjustment` field and persist across page reloads.
|
||||
- **FR-109**: D&D stat blocks MUST include a Min/Avg/Max hit point toggle below the Hit Points line, defaulting to "Avg".
|
||||
- **FR-110**: The Min/Avg/Max toggle MUST only be shown for a bestiary-linked combatant whose HP formula is a plain Hit Dice pool (`NdM`, optionally `± K`) with a spread — it MUST NOT be shown while browsing a creature without a combatant, for PF2e creatures, or when the HP field carries prose instead of dice.
|
||||
- **FR-111**: "Min" MUST use the result of every Hit Die rolling 1 (dice count + modifier); "Max" MUST use the result of every Hit Die rolling its highest face (dice count × faces + modifier). Both MUST be at least 1. "Avg" MUST use the printed average.
|
||||
- **FR-112**: Selecting a variant MUST shift the combatant's stored maxHp by the delta between the previously selected and newly selected value, preserving manual HP edits. The combatant's currentHp MUST shift by the same delta, clamped to [0, new maxHp].
|
||||
- **FR-113**: Selecting a variant MUST NOT change the combatant's name or any stat other than HP.
|
||||
- **FR-114**: The selected variant MUST be stored on the combatant as an `hpVariant` field (`"min" | "max"`, absent for average) and persist across page reloads and JSON export/import.
|
||||
|
||||
### Acceptance Scenarios
|
||||
|
||||
@@ -194,6 +205,11 @@ When viewing a PF2e creature's stat block, a Weak/Normal/Elite toggle appears in
|
||||
38. **Given** a PF2e creature with level 1 stat block is open, **When** the DM selects "Weak", **Then** the level decreases by 2 (to −1, not 0).
|
||||
39. **Given** a PF2e combatant was set to "Elite" and the page is reloaded, **When** the DM opens the stat block, **Then** the toggle shows "Elite" and the stat block displays adjusted stats.
|
||||
40. **Given** a PF2e combatant was set to "Elite", **When** the DM toggles back to "Normal", **Then** the stat block reverts to base stats, the combatant's HP/AC revert, and the name prefix is removed.
|
||||
41. **Given** a D&D combatant's stat block is open, **When** the DM views the Hit Points line, **Then** a Min/Avg/Max toggle is visible, set to "Avg".
|
||||
42. **Given** a D&D combatant with `3d6 + 6` hit points (average 16) is at full health, **When** the DM selects "Max", **Then** the stat block shows 24 hit points and the combatant's HP becomes 24/24.
|
||||
43. **Given** the same combatant, **When** the DM selects "Min", **Then** the stat block shows 9 hit points and the combatant's HP becomes 9/9.
|
||||
44. **Given** a D&D combatant set to "Max" has taken 5 damage (19/24), **When** the DM selects "Avg", **Then** the combatant's HP becomes 11/16 — the damage taken is preserved.
|
||||
45. **Given** a D&D combatant was set to "Max" and the page is reloaded, **When** the DM opens the stat block, **Then** the toggle shows "Max" and the maximum hit points are displayed.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
@@ -216,6 +232,11 @@ When viewing a PF2e creature's stat block, a Weak/Normal/Elite toggle appears in
|
||||
- Attack with multiple on-hit effects (e.g., `["grab", "knockdown"]`): all effects shown, joined with "and" (e.g., "plus Grab and Knockdown").
|
||||
- Attack effect slug with creature-name prefix (e.g., `"lich-siphon-life"` on a Lich): the creature-name prefix is stripped, rendering as "Siphon Life".
|
||||
- Frequency `per` value variations (e.g., "day", "round", "turn"): the value is rendered as-is in the "(N/per)" format.
|
||||
- HP formula that is not a dice pool (empty, prose such as "equal to the summoner's", or a flat number): the Min/Avg/Max toggle is omitted and the printed average is shown unchanged.
|
||||
- HP formula written with an en dash or em dash instead of a hyphen (present in bundled source data, e.g. `2d6 – 2`): parsed as a negative modifier.
|
||||
- Hit Dice modifier larger than the dice pool (e.g. `1d4 - 10`): the resulting hit points are floored at 1.
|
||||
- Toggling from Min to Max: applies the full swing in a single operation.
|
||||
- Combatant whose max HP was edited by hand before toggling: the edit is preserved as an offset, since the toggle applies the delta between variants rather than an absolute value.
|
||||
|
||||
---
|
||||
|
||||
@@ -396,7 +417,7 @@ As a DM with a creature pinned, I want to collapse the right (browse) panel inde
|
||||
- **Source** (`BestiarySource`): A D&D or PF2e publication identified by a code (e.g., "XMM") with a display name (e.g., "Monster Manual (2025)"). Caching and fetching operate at the source level.
|
||||
- **Creature (Full)** (`Creature`): A complete creature record with all stat block data (traits, actions, legendary actions, spellcasting, etc.), available only after source data is fetched/uploaded and cached. Identified by a branded `CreatureId`. For PF2e creatures, each spell entry inside `spellcasting` carries full per-spell data (slug, level, traits, range, action cost, target/area, duration, defense, description, heightening) extracted from the embedded `items[type=spell]` data on the source NPC, enabling inline spell description display without additional fetches. PF2e creatures also carry an `equipment` list of carried items (weapons, consumables) extracted from `items[type=weapon]` and `items[type=consumable]` entries, each with name, level, traits, description, and (for scrolls) embedded spell data. PF2e attack entries carry an optional `attackEffects` list of on-hit effect names. PF2e ability entries carry an optional `frequency` with `max` and `per` fields. PF2e creature perception carries an optional `details` string (e.g., "smoke vision").
|
||||
- **Cached Source Data**: The full normalized bestiary data for a single source, stored in IndexedDB. Contains complete creature stat blocks.
|
||||
- **Combatant** (extended): Gains an optional `creatureId` reference to a `Creature`, enabling stat block lookup and stat pre-fill on creation. PF2e bestiary-linked combatants may also carry a `creatureAdjustment` (`"weak" | "elite"`) indicating the active PF2e weak/elite adjustment, persisted across reloads.
|
||||
- **Combatant** (extended): Gains an optional `creatureId` reference to a `Creature`, enabling stat block lookup and stat pre-fill on creation. PF2e bestiary-linked combatants may also carry a `creatureAdjustment` (`"weak" | "elite"`) indicating the active PF2e weak/elite adjustment, persisted across reloads. D&D bestiary-linked combatants may carry an `hpVariant` (`"min" | "max"`) indicating that their max HP was set to the lowest or highest possible Hit Dice result instead of the printed average, likewise persisted across reloads.
|
||||
- **Queued Creature**: Transient UI-only state representing a bestiary creature selected for batch-add, containing the creature reference and a count (1+). Not persisted.
|
||||
- **Bulk Import Operation**: Tracks total sources, completed count, failed count, and current status (idle / loading / complete / partial-failure).
|
||||
- **Toast Notification**: Lightweight custom UI element at bottom-center of screen with text, optional progress bar, and optional dismiss button.
|
||||
|
||||
@@ -75,6 +75,30 @@ When adding combatants to an encounter, the GM can search for their saved player
|
||||
|
||||
---
|
||||
|
||||
**Story PC-8 — Add the whole party to an encounter (Priority: P1)**
|
||||
|
||||
At the start of a session the GM wants the entire party in the initiative tracker. Instead of searching for each character by name, they open the player character management view and add every saved character to the encounter in one action.
|
||||
|
||||
**Why this priority**: Putting the party on the board is how nearly every encounter starts. Doing it one character at a time is the main friction in the setup flow.
|
||||
|
||||
**Independent Test**: Can be tested by creating several player characters, triggering the add-party action, and verifying every character is present as a combatant.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** player characters "Aragorn", "Legolas", and "Gimli" exist and none are in the encounter, **When** the user triggers the add-party action from the management view, **Then** a combatant is created for each of them, with the same stats, color, and icon an individual add would produce.
|
||||
|
||||
2. **Given** "Aragorn" is already a combatant in the encounter and "Legolas" is not, **When** the user triggers the add-party action, **Then** only "Legolas" is added — "Aragorn" gets no second copy and is not renamed.
|
||||
|
||||
3. **Given** the user has just added the party, **When** the user undoes once, **Then** every combatant created by that action is removed together.
|
||||
|
||||
4. **Given** every saved player character is already in the encounter, **When** the user opens the management view, **Then** the add-party action is unavailable and communicates why.
|
||||
|
||||
5. **Given** some player characters are already in the encounter, **When** the user opens the management view, **Then** those characters are marked as already present and the add-party action remains available for the rest.
|
||||
|
||||
6. **Given** no player characters exist, **When** the user opens the management view, **Then** the empty state is shown without an add-party action.
|
||||
|
||||
---
|
||||
|
||||
### Displaying Player Characters in Encounters
|
||||
|
||||
**Story PC-4 — Visual distinction for player character combatants (Priority: P2)**
|
||||
@@ -152,7 +176,10 @@ The GM no longer needs a player character and wants to remove it from their save
|
||||
### Edge Cases
|
||||
|
||||
- **Duplicate player character names**: Permitted. Player characters are identified by a unique internal ID, not by name.
|
||||
- **Adding the same player character to an encounter multiple times**: Each addition creates an independent combatant copy. Multiple copies of the same PC in one encounter are allowed.
|
||||
- **Adding the same player character to an encounter multiple times**: Each addition creates an independent combatant copy. Multiple copies of the same PC in one encounter are allowed. The add-party action is the deliberate exception — it skips characters that are already present (see PC-8).
|
||||
- **Adding the party when part of it is already present**: Only the missing characters are added. Membership is determined by player character identity, not by name.
|
||||
- **Adding the party after a PC-derived combatant was renamed**: The combatant is still recognised as that player character, so the party add does not produce a duplicate.
|
||||
- **Adding the party after the source player character was deleted**: The orphaned combatant matches no saved character; it is left alone and the remaining party members add normally.
|
||||
- **Editing a player character while it is also a combatant in the active encounter**: The active combatant is not affected; only future additions use the updated stats.
|
||||
- **Deleting a player character while it is a combatant in the active encounter**: The combatant remains in the encounter unchanged.
|
||||
- **Very long player character names**: The UI should truncate or ellipsize names that exceed the available space.
|
||||
@@ -224,10 +251,23 @@ The system MUST allow deleting a player character with two-step confirmation (Co
|
||||
#### FR-019 — Management: Delete does not affect active combatants
|
||||
Deleting a player character MUST NOT remove or modify any combatants currently in an encounter.
|
||||
|
||||
#### FR-020 — Add to encounter: Add the whole party
|
||||
The management view MUST provide an action that adds all saved player characters to the current encounter in a single interaction.
|
||||
|
||||
#### FR-021 — Add to encounter: Party add skips present members
|
||||
Adding the party MUST skip player characters that already have a combatant in the current encounter. Membership MUST be determined by player character identity, not by combatant name.
|
||||
|
||||
#### FR-022 — Add to encounter: Party add is one undo step
|
||||
Adding the party MUST be reversible as a single undo step, regardless of how many combatants it created.
|
||||
|
||||
#### FR-023 — Management: Indicate encounter membership
|
||||
The management view MUST indicate which saved player characters are already in the current encounter, and MUST disable the add-party action when none remain to be added.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **PlayerCharacter**: A persistent, reusable character template with a unique `PlayerCharacterId` (branded string), required `name`, `ac` (number), `maxHp` (number), `color` (string from predefined set), `icon` (string identifier from preset icon set), and optional `level` (integer 1-20, added by spec 008 for encounter difficulty calculation).
|
||||
- **PlayerCharacterStore** (port): Interface for loading, saving, and deleting player characters. Implemented as a browser storage adapter.
|
||||
- **Combatant → PlayerCharacter link**: A combatant created from a player character retains that character's `PlayerCharacterId`. The link drives display (color, icon) and encounter-membership detection (FR-021). It does not make the combatant a live view of the character — the combatant remains an independent copy (FR-014).
|
||||
|
||||
---
|
||||
|
||||
@@ -244,6 +284,8 @@ Deleting a player character MUST NOT remove or modify any combatants currently i
|
||||
- **SC-007**: All player character domain operations (create, edit, delete) are pure functions with no I/O, consistent with the project's deterministic domain core.
|
||||
- **SC-008**: The player character domain module has zero imports from application, adapter, or UI layers.
|
||||
- **SC-009**: Corrupt or missing player character data never causes a crash — the application gracefully falls back to an empty player character list.
|
||||
- **SC-010**: Users can put their entire party into an encounter with one interaction, and reverse it with one undo — no matter how large the party is.
|
||||
- **SC-011**: Repeating the add-party action never produces duplicate or auto-renamed copies of a player character already in the encounter.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["packages/*/src/**/*.test.ts", "apps/*/src/**/*.test.{ts,tsx}"],
|
||||
passWithNoTests: true,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
enabled: true,
|
||||
|
||||
Reference in New Issue
Block a user