Temp HP absorbs damage before current HP, cannot be healed, and does not stack (higher value wins). Displayed as cyan +N after current HP with a Shield button in the HP adjustment popover. Column space is reserved across all rows only when any combatant has temp HP. Concentration pulse fires on any damage, including damage fully absorbed by temp HP. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
430 lines
9.7 KiB
TypeScript
430 lines
9.7 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,
|
|
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;
|
|
}
|
|
|
|
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[]>([]);
|
|
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, opts?: CombatantOpts) => {
|
|
const id = combatantId(`c-${++nextId.current}`);
|
|
const result = addCombatantUseCase(makeStore(), id, name);
|
|
|
|
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],
|
|
);
|
|
|
|
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,
|
|
);
|
|
|
|
// 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) {
|
|
editCombatantUseCase(makeStore(), target.id, to);
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
),
|
|
});
|
|
|
|
setEvents((prev) => [...prev, ...addResult]);
|
|
|
|
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 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,
|
|
),
|
|
});
|
|
|
|
setEvents((prev) => [...prev, ...addResult]);
|
|
},
|
|
[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;
|
|
}
|