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
@@ -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(