Compare commits

..
11 Commits
Author SHA1 Message Date
LukasandClaude Opus 5 c029c0ca8d Add the whole party to an encounter from the player menu
CI / check (push) Successful in 2m53s
CI / build-image (push) Successful in 20s
Starting an encounter meant searching for each party member by name, one
at a time. The management view now carries an "Add party to encounter"
button that puts all of them on the board in one click and closes, so the
initiative list is what you see next.

Members already in the encounter are skipped. Matching is on
playerCharacterId, not on name — otherwise a second click would hand you
"Thorin 2" via the auto-numbering, and renaming a combatant to match a
mini (the reason the rename feature exists) would break the check. Rows
for those members show a muted swords icon, and the button disables once
nothing is left to add, so the greyed-out state has a visible cause.

The whole batch pushes a single undo entry rather than one per combatant:
undoing a four-person party should not take four keystrokes. That is why
the reducer loops internally instead of the UI dispatching N times, and
why addOneFromPlayerCharacter is now split out of the single-add handler
for both paths to share.

Spec 005 gains story PC-8, FR-020..FR-023, SC-010/SC-011 and the edge
cases around partial parties, renamed combatants and orphaned ones. The
existing "multiple copies of the same PC are allowed" edge case is marked
as still true for individual adds — the party button is the deliberate
exception. Key Entities now documents the combatant/PC link the dedup
relies on, which had never been written down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:58:14 +02:00
LukasandClaude Opus 5 2e4865d5fc Upgrade Knip back to 6, with raw transfer disabled
CI / check (push) Successful in 2m46s
CI / build-image (push) Successful in 47s
Knip 5 is effectively frozen: 5.88.1 (2026-03-19) was its last release,
with only three patches after 6.0.0 shipped and none since.

The reason for the earlier downgrade (968cc72) was misdiagnosed. The 6 GiB
allocation is not the Rust-side arena tracked in oxc-project/oxc#20513, but
a JS-side `new ArrayBuffer(BLOCK_SIZE + BLOCK_ALIGN)` in oxc-parser's
raw-transfer path, allocated by V8. Linux refuses it under heuristic
overcommit on the CI runner (3.7 GB RAM, no swap). The upstream allocator
work that has landed does not touch this path, so waiting on it would not
have helped.

Set KNIP_DISABLE_RAW_TRANSFER=1 in the `knip` script instead, and route
lefthook and `check` through the script rather than the bare binary — the
default path would otherwise crash with an opaque RangeError. Raw transfer
is worth ~5% on a repo this size (3.1s vs 3.15s); the upstream 2-4x figure
applies to large codebases. Knip 6 is still ~20% faster than 5 overall.

No change in unused-code detection. Verified under overcommit_memory=0 on
Linux as well as locally.

Closes #11

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:24:20 +02:00
LukasandClaude Opus 5 be3778a0c9 Add min/max hit point variants for D&D creatures
A D&D stat block opened for a combatant now offers a Min/Avg/Max toggle
below the Hit Points line, deriving both ends from the Hit Dice formula
(3d6 + 6 -> 9 / 16 / 24). The switch is applied as a delta, so manual HP
edits and damage already taken survive it. Unlike the PF2e weak/elite
adjustment this is a convenience tool, not a rules mechanic: nothing but
HP changes and the combatant is not renamed. The toggle is hidden when
the HP field carries prose instead of a dice pool.

Also extracts a SegmentedControl primitive. Game system, theme and the
PF2e weak/elite toggle were three hand-rolled copies of the same markup,
which is why the D&D toggle first came out chunkier and in the wrong
accent color. All four now share one definition, and segments expose
aria-pressed instead of signalling the active state by color alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:06:01 +02:00
LukasandClaude Opus 5 7b599e9ad9 Portal the HP adjust popover out of the dimmed row subtree
CI / check (push) Successful in 2m55s
CI / build-image (push) Skipped
A combatant at 0 HP renders its row sections with opacity-50, and the HP
popover lived inside the HP section. CSS opacity applies to the whole
subtree, so position: fixed did not escape it — the popover you need to
heal a downed creature came up half-transparent.

Render it through createPortal into document.body, matching what
ConditionPicker and DetailPopover already do. Positioning now takes an
anchorRef instead of reading parentElement, since the portal's parent is
the body, and the z-index moves to z-50 alongside the other portaled
popovers.

The standalone popover test has to mount the anchor before the popover:
React attaches a parent's ref after its children's layout effects run, so
a same-mount anchorRef is still null when the popover measures itself.
That matches the app, where the popover only opens on click.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:47:24 +02:00
LukasandClaude Opus 5 5d99764e14 Make jscpd actually scan, and tighten its threshold to 3%
.jscpd.json set pattern to an array, but jscpd's --pattern takes a single
glob string. An array matches nothing, so the gate had reported success
without reading a file since 2793a66 introduced it — its 0.074ms
"detection time" and absent stats table were the tell. Bisecting the
config confirmed that key alone reduces the run to zero files.

The ignore entries were also bare names rather than globs, so once the
pattern worked it swept apps/web/dist and .pnpm-store — 948 files.

Excludes __tests__, matching what .jsinspectrc already does. Duplication
between test cases is usually deliberate: parallel arrange/act/assert
blocks read better than shared setup, and the factories under
apps/web/src/__tests__/factories cover the deduplication worth having.

Real duplication is 1.72% across 209 source files, so the threshold drops
from 5% to 3%. jscpd measures a ratio rather than blocking each new clone,
and a 5% budget left roughly 3x headroom before it would ever fire.

Gives jscpd explicit paths behind `pnpm jscpd`, so lefthook and the check
script share one invocation, as jsinspect already does.

Also guards the pattern key in check-gates.mjs, since an array there fails
silently rather than erroring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:27:51 +02:00
LukasandClaude Opus 5 67c398d3a2 Make the test gate fail when no tests match
vitest.config.ts set passWithNoTests: true, added in the 7dd4abb
scaffolding commit when the repo genuinely had no tests yet. Once tests
existed it became the same hazard as the oxlint bugs fixed in 91f46ff:
if the include globs ever stopped matching, the suite would exit 0
having run nothing.

Vitest already defaults to failing in that case, so removing the line is
the whole fix — a broken glob now reports "No test files found, exiting
with code 1". Verified by pointing the globs at a nonexistent directory.

Extends the gate check to flag the option coming back, and renames it
from check-lint-gate to check-gates now that it covers more than oxlint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:16:04 +02:00
LukasandClaude Opus 5 8204122bd0 Lint the scripts directory
`scripts` sat in oxlint's ignorePatterns since c94c30e, grouped with
dist, coverage and .pnpm-store. Those are build artifacts; scripts holds
the quality gates themselves, which went unlinted as a result.

Removing it raises coverage from 265 to 273 files and surfaced four
findings:

prefer-regexp-exec in three files — swapped String#match for RegExp#exec.
All three patterns are non-global, where the two methods return the same
result, so behaviour is unchanged.

require-array-sort-compare in generate-bestiary-index.mjs — the rule
skips string arrays, and fired only because `new Set()` infers Set<any>.
Typed it Set<string>, which it already is, rather than adding a
comparator the bare sort does not need.

Also anchors the lint-gate probe's no-console canary to CLI scripts that
exist to write to the console, rather than to app error paths that could
reasonably be removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:11:23 +02:00
LukasandClaude Opus 5 685526d53b Add a regression test for the oxlint gate
A gate that checks nothing exits 0, which is indistinguishable from a
clean run — that is why the two invocation bugs fixed in 91f46ff went
unnoticed across 49 commits.

The check runs the configured oxlint command with one rule forced to
warn and requires a non-zero exit. That single probe covers a missing
--deny-warnings, a command that discovers no files, a broken tsconfig
path, and an upstream flag rename. Two things the probe cannot see get
their own assertions: that every gate invokes `pnpm oxlint` with nothing
appended, and that --type-aware is still enabled, since the canary rule
is not type-aware.

Verified against four reconstructed states: the b6ee4c8 and 9b0cb38
configurations, --type-aware removed, and a tsconfig path that matches
no files. Each fails; the current configuration passes.

Follows the existing check-layer-boundaries.mjs pattern — an exported
function driven by a Vitest case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:10:59 +02:00
LukasandClaude Opus 5 91f46ff3c8 Fix oxlint gate invocations that silently linted nothing
Two separate defects, both of which made the gate pass without checking:

lefthook ran `pnpm oxlint -- --deny warnings`. pnpm forwards arguments
after `--` to the script, so oxlint received its own `--` terminator and
read the rest as file paths — the pre-commit oxlint job linted 0 files.

The check script used `--deny warnings` rather than `--deny-warnings`.
`--deny` takes a rule or category and `warnings` is neither, so it was
accepted and ignored. Without `--deny-warnings` the run reports warnings
but still exits 0, so CI was equally non-blocking.

Both now call `pnpm oxlint`, keeping the flags in one place.

Introduced 2026-03-29 in b6ee4c8, which claimed warnings would fail the
build. 9b0cb38 diagnosed the invalid flag but fixed only the npm script,
leaving both actual gates broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:10:31 +02:00
LukasandClaude Opus 5 90eb39b227 Restore green merge gate: pnpm settings, deps, unbound-method
pnpm 10.32 stopped reading the "pnpm" field from package.json, so the
undici/picomatch overrides and the three GHSA suppressions had silently
stopped applying — only the stale lockfile still held undici at 7.24.8.
Move the surviving picomatch override to pnpm-workspace.yaml.

With the config live again, two high advisories surfaced:
- postcss was stuck at 8.5.15 (GHSA-r28c-9q8g-f849, patched in 8.5.18);
  nothing pinned it, so refresh to 8.5.25 within vite's range.
- undici GHSA-4cwx-7wf7-3272 needed >=7.29.0, but our ~7.24.0 pin existed
  because jsdom 29 crashed on undici 7.28+. jsdom 30 moved to undici ^8.9,
  so bump jsdom and drop both the pin and all three suppressions.

15 vulnerabilities (5 high, 3 suppressed) down to 1 moderate (smol-toml
via knip, below the --audit-level=high gate).

Separately, ports.ts declared its members with method shorthand, which
TypeScript treats as this-dependent, so oxlint's unbound-method fired
wherever a port was passed by reference (use-bestiary.ts:236). The ports
are bags of plain module functions, so declare them as readonly function
properties instead of suppressing the one call site. This makes parameter
types contravariant rather than bivariant; typecheck passes unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:24:13 +02:00
LukasandClaude Fable 5 78079bf1b2 Break import cycle in domain persistent-damage modules
Extract persistent damage types and definitions into a leaf module so
events.ts and types.ts no longer import back into persistent-damage.ts.
Enable oxlint import/no-cycle (whole workspace via root tsconfig) to
keep cycles out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:35:14 +02:00
51 changed files with 2066 additions and 516 deletions
+9 -3
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-type-annotations/refs/heads/main/packages/oxlint/configuration_file_schema.json",
"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"
]
}
+1 -1
View File
@@ -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";
+26 -23
View File
@@ -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);
+3 -1
View File
@@ -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)}
+63 -3
View File
@@ -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}
+11 -8
View File
@@ -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,
);
}
+16 -21
View File
@@ -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);
}}
/>
</>
);
+34 -2
View File
@@ -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
+47 -48
View File
@@ -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>
+32 -8
View File
@@ -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([]);
});
});
+78 -7
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
+1
View File
@@ -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,
},
]);
});
});
+10 -1
View File
@@ -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
+58
View File
@@ -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;
}
+12
View File
@@ -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",
},
];
+13 -77
View File
@@ -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;
+7 -2
View File
@@ -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),
+73
View File
@@ -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,
},
],
};
}
+3 -1
View File
@@ -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;
+495 -252
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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"
+83
View File
@@ -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;
}
+1 -1
View File
@@ -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];
+1 -1
View File
@@ -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++;
+1
View File
@@ -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()) {
+22 -1
View File
@@ -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-113FR-116), and its name gains a prefix (see `specs/001-combatant-management/spec.md`, FR-041FR-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-041FR-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.
+43 -1
View File
@@ -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.
---
-1
View File
@@ -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,