From c029c0ca8d492f44762536495eb7654d36ff2b66 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 5 Aug 2026 17:58:14 +0200 Subject: [PATCH] Add the whole party to an encounter from the player menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../factories/build-player-character.ts | 16 +++++ apps/web/src/__tests__/factories/index.ts | 1 + .../player-character-section.test.tsx | 50 +++++++++++---- .../__tests__/player-management.test.tsx | 57 ++++++++++++++++- .../components/player-character-section.tsx | 19 +++++- apps/web/src/components/player-management.tsx | 36 ++++++++++- .../hooks/__tests__/use-encounter.test.tsx | 51 +++++++++++++++ apps/web/src/hooks/use-encounter.ts | 62 ++++++++++++++++--- specs/005-player-characters/spec.md | 44 ++++++++++++- 9 files changed, 312 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/__tests__/factories/build-player-character.ts diff --git a/apps/web/src/__tests__/factories/build-player-character.ts b/apps/web/src/__tests__/factories/build-player-character.ts new file mode 100644 index 0000000..cc12bcf --- /dev/null +++ b/apps/web/src/__tests__/factories/build-player-character.ts @@ -0,0 +1,16 @@ +import type { PlayerCharacter } from "@initiative/domain"; +import { playerCharacterId } from "@initiative/domain"; + +let counter = 0; + +export function buildPlayerCharacter( + overrides?: Partial, +): PlayerCharacter { + return { + id: playerCharacterId(`pc-${++counter}`), + name: "Player Character", + ac: 15, + maxHp: 25, + ...overrides, + }; +} diff --git a/apps/web/src/__tests__/factories/index.ts b/apps/web/src/__tests__/factories/index.ts index 2b5d189..5d08a33 100644 --- a/apps/web/src/__tests__/factories/index.ts +++ b/apps/web/src/__tests__/factories/index.ts @@ -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"; diff --git a/apps/web/src/components/__tests__/player-character-section.test.tsx b/apps/web/src/components/__tests__/player-character-section.test.tsx index 62cdf71..f3b2146 100644 --- a/apps/web/src/components/__tests__/player-character-section.test.tsx +++ b/apps/web/src/components/__tests__/player-character-section.test.tsx @@ -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(); + const adapters = createTestAdapters({ playerCharacters }); const result = render(, { - wrapper: AllProviders, + wrapper: ({ children }) => ( + {children} + ), }); 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(); + }); }); diff --git a/apps/web/src/components/__tests__/player-management.test.tsx b/apps/web/src/components/__tests__/player-management.test.tsx index 69c988b..fdfe05f 100644 --- a/apps/web/src/components/__tests__/player-management.test.tsx +++ b/apps/web/src/components/__tests__/player-management.test.tsx @@ -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(), + onAddParty: vi.fn(), ...overrides, }; return { ...render(), 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(); + }); }); diff --git a/apps/web/src/components/player-character-section.tsx b/apps/web/src/components/player-character-section.tsx index a1ade82..0de34d7 100644 --- a/apps/web/src/components/player-character-section.tsx +++ b/apps/web/src/components/player-character-section.tsx @@ -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(); + 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); + }} /> ); diff --git a/apps/web/src/components/player-management.tsx b/apps/web/src/components/player-management.tsx index 76c48b1..31e16d1 100644 --- a/apps/web/src/components/player-management.tsx +++ b/apps/web/src/components/player-management.tsx @@ -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; + 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) { + const addableCount = characters.filter( + (pc) => !inEncounterIds.has(pc.id), + ).length; + return ( @@ -61,6 +71,16 @@ export function PlayerManagement({ Lv {pc.level} )} + {inEncounterIds.has(pc.id) && ( + + + + )}