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>
352 lines
7.6 KiB
TypeScript
352 lines
7.6 KiB
TypeScript
import type { EncounterStore } from "@initiative/application";
|
|
import {
|
|
addCombatantUseCase,
|
|
adjustHpUseCase,
|
|
advanceTurnUseCase,
|
|
clearEncounterUseCase,
|
|
editCombatantUseCase,
|
|
removeCombatantUseCase,
|
|
retreatTurnUseCase,
|
|
setAcUseCase,
|
|
setHpUseCase,
|
|
setInitiativeUseCase,
|
|
setTempHpUseCase,
|
|
toggleConcentrationUseCase,
|
|
toggleConditionUseCase,
|
|
} from "@initiative/application";
|
|
import type {
|
|
BestiaryIndexEntry,
|
|
CombatantId,
|
|
CombatantInit,
|
|
ConditionId,
|
|
CreatureId,
|
|
DomainEvent,
|
|
Encounter,
|
|
PlayerCharacter,
|
|
} from "@initiative/domain";
|
|
import {
|
|
combatantId,
|
|
isDomainError,
|
|
creatureId as makeCreatureId,
|
|
resolveCreatureName,
|
|
} from "@initiative/domain";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import {
|
|
loadEncounter,
|
|
saveEncounter,
|
|
} from "../persistence/encounter-storage.js";
|
|
|
|
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
|
|
|
const EMPTY_ENCOUNTER: Encounter = {
|
|
combatants: [],
|
|
activeIndex: 0,
|
|
roundNumber: 1,
|
|
};
|
|
|
|
function initializeEncounter(): Encounter {
|
|
const stored = loadEncounter();
|
|
if (stored !== null) return stored;
|
|
return EMPTY_ENCOUNTER;
|
|
}
|
|
|
|
function deriveNextId(encounter: Encounter): number {
|
|
let max = 0;
|
|
for (const c of encounter.combatants) {
|
|
const match = COMBATANT_ID_REGEX.exec(c.id);
|
|
if (match) {
|
|
const n = Number.parseInt(match[1], 10);
|
|
if (n > max) max = n;
|
|
}
|
|
}
|
|
return max;
|
|
}
|
|
|
|
export function useEncounter() {
|
|
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
|
const [events, setEvents] = useState<DomainEvent[]>([]);
|
|
const encounterRef = useRef(encounter);
|
|
encounterRef.current = encounter;
|
|
|
|
useEffect(() => {
|
|
saveEncounter(encounter);
|
|
}, [encounter]);
|
|
|
|
const makeStore = useCallback((): EncounterStore => {
|
|
return {
|
|
get: () => encounterRef.current,
|
|
save: (e) => {
|
|
encounterRef.current = e;
|
|
setEncounter(e);
|
|
},
|
|
};
|
|
}, []);
|
|
|
|
const advanceTurn = useCallback(() => {
|
|
const result = advanceTurnUseCase(makeStore());
|
|
|
|
if (isDomainError(result)) {
|
|
return;
|
|
}
|
|
|
|
setEvents((prev) => [...prev, ...result]);
|
|
}, [makeStore]);
|
|
|
|
const retreatTurn = useCallback(() => {
|
|
const result = retreatTurnUseCase(makeStore());
|
|
|
|
if (isDomainError(result)) {
|
|
return;
|
|
}
|
|
|
|
setEvents((prev) => [...prev, ...result]);
|
|
}, [makeStore]);
|
|
|
|
const nextId = useRef(deriveNextId(encounter));
|
|
|
|
const addCombatant = useCallback(
|
|
(name: string, init?: CombatantInit) => {
|
|
const id = combatantId(`c-${++nextId.current}`);
|
|
const result = addCombatantUseCase(makeStore(), id, name, init);
|
|
|
|
if (isDomainError(result)) {
|
|
return;
|
|
}
|
|
|
|
setEvents((prev) => [...prev, ...result]);
|
|
},
|
|
[makeStore],
|
|
);
|
|
|
|
const removeCombatant = useCallback(
|
|
(id: CombatantId) => {
|
|
const result = removeCombatantUseCase(makeStore(), id);
|
|
|
|
if (isDomainError(result)) {
|
|
return;
|
|
}
|
|
|
|
setEvents((prev) => [...prev, ...result]);
|
|
},
|
|
[makeStore],
|
|
);
|
|
|
|
const editCombatant = useCallback(
|
|
(id: CombatantId, newName: string) => {
|
|
const result = editCombatantUseCase(makeStore(), id, newName);
|
|
|
|
if (isDomainError(result)) {
|
|
return;
|
|
}
|
|
|
|
setEvents((prev) => [...prev, ...result]);
|
|
},
|
|
[makeStore],
|
|
);
|
|
|
|
const setInitiative = useCallback(
|
|
(id: CombatantId, value: number | undefined) => {
|
|
const result = setInitiativeUseCase(makeStore(), id, value);
|
|
|
|
if (isDomainError(result)) {
|
|
return;
|
|
}
|
|
|
|
setEvents((prev) => [...prev, ...result]);
|
|
},
|
|
[makeStore],
|
|
);
|
|
|
|
const setHp = useCallback(
|
|
(id: CombatantId, maxHp: number | undefined) => {
|
|
const result = setHpUseCase(makeStore(), id, maxHp);
|
|
|
|
if (isDomainError(result)) {
|
|
return;
|
|
}
|
|
|
|
setEvents((prev) => [...prev, ...result]);
|
|
},
|
|
[makeStore],
|
|
);
|
|
|
|
const adjustHp = useCallback(
|
|
(id: CombatantId, delta: number) => {
|
|
const result = adjustHpUseCase(makeStore(), id, delta);
|
|
|
|
if (isDomainError(result)) {
|
|
return;
|
|
}
|
|
|
|
setEvents((prev) => [...prev, ...result]);
|
|
},
|
|
[makeStore],
|
|
);
|
|
|
|
const setTempHp = useCallback(
|
|
(id: CombatantId, tempHp: number | undefined) => {
|
|
const result = setTempHpUseCase(makeStore(), id, tempHp);
|
|
|
|
if (isDomainError(result)) {
|
|
return;
|
|
}
|
|
|
|
setEvents((prev) => [...prev, ...result]);
|
|
},
|
|
[makeStore],
|
|
);
|
|
|
|
const setAc = useCallback(
|
|
(id: CombatantId, value: number | undefined) => {
|
|
const result = setAcUseCase(makeStore(), id, value);
|
|
|
|
if (isDomainError(result)) {
|
|
return;
|
|
}
|
|
|
|
setEvents((prev) => [...prev, ...result]);
|
|
},
|
|
[makeStore],
|
|
);
|
|
|
|
const toggleCondition = useCallback(
|
|
(id: CombatantId, conditionId: ConditionId) => {
|
|
const result = toggleConditionUseCase(makeStore(), id, conditionId);
|
|
|
|
if (isDomainError(result)) {
|
|
return;
|
|
}
|
|
|
|
setEvents((prev) => [...prev, ...result]);
|
|
},
|
|
[makeStore],
|
|
);
|
|
|
|
const toggleConcentration = useCallback(
|
|
(id: CombatantId) => {
|
|
const result = toggleConcentrationUseCase(makeStore(), id);
|
|
|
|
if (isDomainError(result)) {
|
|
return;
|
|
}
|
|
|
|
setEvents((prev) => [...prev, ...result]);
|
|
},
|
|
[makeStore],
|
|
);
|
|
|
|
const clearEncounter = useCallback(() => {
|
|
const result = clearEncounterUseCase(makeStore());
|
|
|
|
if (isDomainError(result)) {
|
|
return;
|
|
}
|
|
|
|
nextId.current = 0;
|
|
setEvents((prev) => [...prev, ...result]);
|
|
}, [makeStore]);
|
|
|
|
const addFromBestiary = useCallback(
|
|
(entry: BestiaryIndexEntry): CreatureId | null => {
|
|
const store = makeStore();
|
|
const existingNames = store.get().combatants.map((c) => c.name);
|
|
const { newName, renames } = resolveCreatureName(
|
|
entry.name,
|
|
existingNames,
|
|
);
|
|
|
|
for (const { from, to } of renames) {
|
|
const target = store.get().combatants.find((c) => c.name === from);
|
|
if (target) {
|
|
editCombatantUseCase(makeStore(), target.id, to);
|
|
}
|
|
}
|
|
|
|
const slug = entry.name
|
|
.toLowerCase()
|
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
.replaceAll(/(^-|-$)/g, "");
|
|
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
|
|
|
const id = combatantId(`c-${++nextId.current}`);
|
|
const result = addCombatantUseCase(makeStore(), id, newName, {
|
|
maxHp: entry.hp,
|
|
ac: entry.ac > 0 ? entry.ac : undefined,
|
|
creatureId: cId,
|
|
});
|
|
|
|
if (isDomainError(result)) return null;
|
|
|
|
setEvents((prev) => [...prev, ...result]);
|
|
return cId;
|
|
},
|
|
[makeStore],
|
|
);
|
|
|
|
const addFromPlayerCharacter = useCallback(
|
|
(pc: PlayerCharacter) => {
|
|
const store = makeStore();
|
|
const existingNames = store.get().combatants.map((c) => c.name);
|
|
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
|
|
|
|
for (const { from, to } of renames) {
|
|
const target = store.get().combatants.find((c) => c.name === from);
|
|
if (target) {
|
|
editCombatantUseCase(makeStore(), target.id, to);
|
|
}
|
|
}
|
|
|
|
const id = combatantId(`c-${++nextId.current}`);
|
|
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,
|
|
});
|
|
|
|
if (isDomainError(result)) return;
|
|
|
|
setEvents((prev) => [...prev, ...result]);
|
|
},
|
|
[makeStore],
|
|
);
|
|
|
|
const hasTempHp = encounter.combatants.some(
|
|
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
|
);
|
|
|
|
const isEmpty = encounter.combatants.length === 0;
|
|
const hasCreatureCombatants = encounter.combatants.some(
|
|
(c) => c.creatureId != null,
|
|
);
|
|
const canRollAllInitiative = encounter.combatants.some(
|
|
(c) => c.creatureId != null && c.initiative == null,
|
|
);
|
|
|
|
return {
|
|
encounter,
|
|
events,
|
|
isEmpty,
|
|
hasTempHp,
|
|
hasCreatureCombatants,
|
|
canRollAllInitiative,
|
|
advanceTurn,
|
|
retreatTurn,
|
|
addCombatant,
|
|
clearEncounter,
|
|
removeCombatant,
|
|
editCombatant,
|
|
setInitiative,
|
|
setHp,
|
|
adjustHp,
|
|
setTempHp,
|
|
setAc,
|
|
toggleCondition,
|
|
toggleConcentration,
|
|
addFromBestiary,
|
|
addFromPlayerCharacter,
|
|
makeStore,
|
|
} as const;
|
|
}
|