Add the whole party to an encounter from the player menu
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:
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user