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>
This commit is contained in:
Lukas
2026-08-05 17:58:14 +02:00
co-authored by Claude Opus 5
parent 2e4865d5fc
commit c029c0ca8d
9 changed files with 312 additions and 24 deletions
@@ -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";
@@ -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();
});
});
@@ -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
@@ -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([]);
});
});
+55 -7
View File
@@ -117,6 +117,7 @@ type EncounterAction =
baseCreature: Pf2eCreature;
}
| { type: "add-from-player-character"; pc: PlayerCharacter }
| { type: "add-party"; pcs: readonly PlayerCharacter[] }
| {
type: "import";
encounter: Encounter;
@@ -286,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,
@@ -300,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,
@@ -425,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);
}
@@ -753,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(
+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.
---