Atomic addCombatant with optional CombatantInit bag
addCombatant now accepts an optional init parameter for pre-filled stats (HP, AC, initiative, creatureId, color, icon, playerCharacterId), making combatant creation a single atomic operation with domain validation. This eliminates the multi-step store.save() bypass in addFromBestiary and addFromPlayerCharacter, and removes the CombatantOpts/applyCombatantOpts helpers. Also extracts shared initiative sort logic into initiative-sort.ts used by both addCombatant and setInitiative. Closes #15 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
CombatantId,
|
||||
CombatantInit,
|
||||
ConditionId,
|
||||
CreatureId,
|
||||
DomainEvent,
|
||||
@@ -61,33 +62,6 @@ function deriveNextId(encounter: Encounter): number {
|
||||
return max;
|
||||
}
|
||||
|
||||
interface CombatantOpts {
|
||||
initiative?: number;
|
||||
ac?: number;
|
||||
maxHp?: number;
|
||||
}
|
||||
|
||||
function applyCombatantOpts(
|
||||
makeStore: () => EncounterStore,
|
||||
id: ReturnType<typeof combatantId>,
|
||||
opts: CombatantOpts,
|
||||
): DomainEvent[] {
|
||||
const events: DomainEvent[] = [];
|
||||
if (opts.maxHp !== undefined) {
|
||||
const r = setHpUseCase(makeStore(), id, opts.maxHp);
|
||||
if (!isDomainError(r)) events.push(...r);
|
||||
}
|
||||
if (opts.ac !== undefined) {
|
||||
const r = setAcUseCase(makeStore(), id, opts.ac);
|
||||
if (!isDomainError(r)) events.push(...r);
|
||||
}
|
||||
if (opts.initiative !== undefined) {
|
||||
const r = setInitiativeUseCase(makeStore(), id, opts.initiative);
|
||||
if (!isDomainError(r)) events.push(...r);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
export function useEncounter() {
|
||||
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||
@@ -131,21 +105,14 @@ export function useEncounter() {
|
||||
const nextId = useRef(deriveNextId(encounter));
|
||||
|
||||
const addCombatant = useCallback(
|
||||
(name: string, opts?: CombatantOpts) => {
|
||||
(name: string, init?: CombatantInit) => {
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const result = addCombatantUseCase(makeStore(), id, name);
|
||||
const result = addCombatantUseCase(makeStore(), id, name, init);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts) {
|
||||
const optEvents = applyCombatantOpts(makeStore, id, opts);
|
||||
if (optEvents.length > 0) {
|
||||
setEvents((prev) => [...prev, ...optEvents]);
|
||||
}
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
@@ -288,7 +255,6 @@ export function useEncounter() {
|
||||
existingNames,
|
||||
);
|
||||
|
||||
// Apply renames (e.g., "Goblin" → "Goblin 1")
|
||||
for (const { from, to } of renames) {
|
||||
const target = store.get().combatants.find((c) => c.name === from);
|
||||
if (target) {
|
||||
@@ -296,43 +262,22 @@ export function useEncounter() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add combatant with resolved name
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
||||
if (isDomainError(addResult)) return null;
|
||||
|
||||
// Set HP
|
||||
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
||||
if (!isDomainError(hpResult)) {
|
||||
setEvents((prev) => [...prev, ...hpResult]);
|
||||
}
|
||||
|
||||
// Set AC
|
||||
if (entry.ac > 0) {
|
||||
const acResult = setAcUseCase(makeStore(), id, entry.ac);
|
||||
if (!isDomainError(acResult)) {
|
||||
setEvents((prev) => [...prev, ...acResult]);
|
||||
}
|
||||
}
|
||||
|
||||
// Derive creatureId from source + name
|
||||
const slug = entry.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||
|
||||
// Set creatureId on the combatant (use store.save to keep ref in sync for batch calls)
|
||||
const currentEncounter = store.get();
|
||||
store.save({
|
||||
...currentEncounter,
|
||||
combatants: currentEncounter.combatants.map((c) =>
|
||||
c.id === id ? { ...c, creatureId: cId } : c,
|
||||
),
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const result = addCombatantUseCase(makeStore(), id, newName, {
|
||||
maxHp: entry.hp,
|
||||
ac: entry.ac > 0 ? entry.ac : undefined,
|
||||
creatureId: cId,
|
||||
});
|
||||
|
||||
setEvents((prev) => [...prev, ...addResult]);
|
||||
if (isDomainError(result)) return null;
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
return cId;
|
||||
},
|
||||
[makeStore],
|
||||
@@ -352,40 +297,17 @@ export function useEncounter() {
|
||||
}
|
||||
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const addResult = addCombatantUseCase(makeStore(), id, newName);
|
||||
if (isDomainError(addResult)) return;
|
||||
|
||||
// Set HP
|
||||
const hpResult = setHpUseCase(makeStore(), id, pc.maxHp);
|
||||
if (!isDomainError(hpResult)) {
|
||||
setEvents((prev) => [...prev, ...hpResult]);
|
||||
}
|
||||
|
||||
// Set AC
|
||||
if (pc.ac > 0) {
|
||||
const acResult = setAcUseCase(makeStore(), id, pc.ac);
|
||||
if (!isDomainError(acResult)) {
|
||||
setEvents((prev) => [...prev, ...acResult]);
|
||||
}
|
||||
}
|
||||
|
||||
// Set color, icon, and playerCharacterId on the combatant
|
||||
const currentEncounter = store.get();
|
||||
store.save({
|
||||
...currentEncounter,
|
||||
combatants: currentEncounter.combatants.map((c) =>
|
||||
c.id === id
|
||||
? {
|
||||
...c,
|
||||
color: pc.color,
|
||||
icon: pc.icon,
|
||||
playerCharacterId: pc.id,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
const result = addCombatantUseCase(makeStore(), id, newName, {
|
||||
maxHp: pc.maxHp,
|
||||
ac: pc.ac > 0 ? pc.ac : undefined,
|
||||
color: pc.color,
|
||||
icon: pc.icon,
|
||||
playerCharacterId: pc.id,
|
||||
});
|
||||
|
||||
setEvents((prev) => [...prev, ...addResult]);
|
||||
if (isDomainError(result)) return;
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user