Persistent damage displayed as compact tags with damage type icon and formula (e.g., Flame + "2d6"). Supports fire, bleed, acid, cold, electricity, poison, and mental types. One instance per type, added via sub-picker in the condition picker. PF2e only, persists across reload. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
758 lines
20 KiB
TypeScript
758 lines
20 KiB
TypeScript
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
|
|
import {
|
|
addCombatantUseCase,
|
|
addPersistentDamageUseCase,
|
|
adjustHpUseCase,
|
|
advanceTurnUseCase,
|
|
clearEncounterUseCase,
|
|
decrementConditionUseCase,
|
|
editCombatantUseCase,
|
|
redoUseCase,
|
|
removeCombatantUseCase,
|
|
removePersistentDamageUseCase,
|
|
retreatTurnUseCase,
|
|
setAcUseCase,
|
|
setConditionValueUseCase,
|
|
setCrUseCase,
|
|
setHpUseCase,
|
|
setInitiativeUseCase,
|
|
setSideUseCase,
|
|
setTempHpUseCase,
|
|
toggleConcentrationUseCase,
|
|
toggleConditionUseCase,
|
|
undoUseCase,
|
|
} from "@initiative/application";
|
|
import type {
|
|
CombatantId,
|
|
CombatantInit,
|
|
ConditionId,
|
|
CreatureId,
|
|
DomainError,
|
|
DomainEvent,
|
|
Encounter,
|
|
PersistentDamageType,
|
|
Pf2eCreature,
|
|
PlayerCharacter,
|
|
UndoRedoState,
|
|
} from "@initiative/domain";
|
|
import {
|
|
acDelta,
|
|
clearHistory,
|
|
combatantId,
|
|
hpDelta,
|
|
isDomainError,
|
|
creatureId as makeCreatureId,
|
|
pushUndo,
|
|
resolveCreatureName,
|
|
} from "@initiative/domain";
|
|
import { useCallback, useEffect, useReducer, useRef } from "react";
|
|
import { useAdapters } from "../contexts/adapter-context.js";
|
|
import type { SearchResult } from "./use-bestiary.js";
|
|
|
|
// -- Types --
|
|
|
|
type EncounterAction =
|
|
| { type: "advance-turn" }
|
|
| { type: "retreat-turn" }
|
|
| { type: "add-combatant"; name: string; init?: CombatantInit }
|
|
| { type: "remove-combatant"; id: CombatantId }
|
|
| { type: "edit-combatant"; id: CombatantId; newName: string }
|
|
| { type: "set-initiative"; id: CombatantId; value: number | undefined }
|
|
| { type: "set-hp"; id: CombatantId; maxHp: number | undefined }
|
|
| { type: "adjust-hp"; id: CombatantId; delta: number }
|
|
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
|
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
|
| { type: "set-cr"; id: CombatantId; value: string | undefined }
|
|
| { type: "set-side"; id: CombatantId; value: "party" | "enemy" }
|
|
| {
|
|
type: "toggle-condition";
|
|
id: CombatantId;
|
|
conditionId: ConditionId;
|
|
}
|
|
| {
|
|
type: "set-condition-value";
|
|
id: CombatantId;
|
|
conditionId: ConditionId;
|
|
value: number;
|
|
}
|
|
| {
|
|
type: "decrement-condition";
|
|
id: CombatantId;
|
|
conditionId: ConditionId;
|
|
}
|
|
| { type: "toggle-concentration"; id: CombatantId }
|
|
| {
|
|
type: "add-persistent-damage";
|
|
id: CombatantId;
|
|
damageType: PersistentDamageType;
|
|
formula: string;
|
|
}
|
|
| {
|
|
type: "remove-persistent-damage";
|
|
id: CombatantId;
|
|
damageType: PersistentDamageType;
|
|
}
|
|
| { type: "clear-encounter" }
|
|
| { type: "undo" }
|
|
| { type: "redo" }
|
|
| { type: "add-from-bestiary"; entry: SearchResult }
|
|
| {
|
|
type: "add-multiple-from-bestiary";
|
|
entry: SearchResult;
|
|
count: number;
|
|
}
|
|
| {
|
|
type: "set-creature-adjustment";
|
|
id: CombatantId;
|
|
adjustment: "weak" | "elite" | undefined;
|
|
baseCreature: Pf2eCreature;
|
|
}
|
|
| { type: "add-from-player-character"; pc: PlayerCharacter }
|
|
| {
|
|
type: "import";
|
|
encounter: Encounter;
|
|
undoRedoState: UndoRedoState;
|
|
};
|
|
|
|
export interface EncounterState {
|
|
readonly encounter: Encounter;
|
|
readonly undoRedoState: UndoRedoState;
|
|
readonly events: readonly DomainEvent[];
|
|
readonly nextId: number;
|
|
readonly lastCreatureId: CreatureId | null;
|
|
}
|
|
|
|
// -- Initialization --
|
|
|
|
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
|
|
|
const EMPTY_ENCOUNTER: Encounter = {
|
|
combatants: [],
|
|
activeIndex: 0,
|
|
roundNumber: 1,
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
function initializeState(
|
|
loadEncounterFn: () => Encounter | null,
|
|
loadUndoRedoFn: () => UndoRedoState,
|
|
): EncounterState {
|
|
const encounter = loadEncounterFn() ?? EMPTY_ENCOUNTER;
|
|
return {
|
|
encounter,
|
|
undoRedoState: loadUndoRedoFn(),
|
|
events: [],
|
|
nextId: deriveNextId(encounter),
|
|
lastCreatureId: null,
|
|
};
|
|
}
|
|
|
|
// -- Helpers --
|
|
|
|
function makeStoreFromState(state: EncounterState): {
|
|
store: EncounterStore;
|
|
getEncounter: () => Encounter;
|
|
} {
|
|
let current = state.encounter;
|
|
return {
|
|
store: {
|
|
get: () => current,
|
|
save: (e) => {
|
|
current = e;
|
|
},
|
|
},
|
|
getEncounter: () => current,
|
|
};
|
|
}
|
|
|
|
function resolveAndRename(store: EncounterStore, name: string): string {
|
|
const existingNames = store.get().combatants.map((c) => c.name);
|
|
const { newName, renames } = resolveCreatureName(name, existingNames);
|
|
|
|
for (const { from, to } of renames) {
|
|
const target = store.get().combatants.find((c) => c.name === from);
|
|
if (target) {
|
|
editCombatantUseCase(store, target.id, to);
|
|
}
|
|
}
|
|
|
|
return newName;
|
|
}
|
|
|
|
function addOneFromBestiary(
|
|
store: EncounterStore,
|
|
entry: SearchResult,
|
|
nextId: number,
|
|
): {
|
|
cId: CreatureId;
|
|
events: DomainEvent[];
|
|
nextId: number;
|
|
} | null {
|
|
const newName = resolveAndRename(store, entry.name);
|
|
|
|
const slug = entry.name
|
|
.toLowerCase()
|
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
.replaceAll(/(^-|-$)/g, "");
|
|
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
|
|
|
const id = combatantId(`c-${nextId + 1}`);
|
|
const result = addCombatantUseCase(store, id, newName, {
|
|
maxHp: entry.hp > 0 ? entry.hp : undefined,
|
|
ac: entry.ac > 0 ? entry.ac : undefined,
|
|
creatureId: cId,
|
|
});
|
|
|
|
if (isDomainError(result)) return null;
|
|
|
|
return { cId, events: result, nextId: nextId + 1 };
|
|
}
|
|
|
|
// -- Reducer case handlers --
|
|
|
|
function handleUndoRedo(
|
|
state: EncounterState,
|
|
direction: "undo" | "redo",
|
|
): EncounterState {
|
|
const { store, getEncounter } = makeStoreFromState(state);
|
|
const undoRedoStore: UndoRedoStore = {
|
|
get: () => state.undoRedoState,
|
|
save: () => {},
|
|
};
|
|
const applyFn = direction === "undo" ? undoUseCase : redoUseCase;
|
|
const result = applyFn(store, undoRedoStore);
|
|
if (isDomainError(result)) return state;
|
|
|
|
const isUndo = direction === "undo";
|
|
return {
|
|
...state,
|
|
encounter: getEncounter(),
|
|
undoRedoState: {
|
|
undoStack: isUndo
|
|
? state.undoRedoState.undoStack.slice(0, -1)
|
|
: [...state.undoRedoState.undoStack, state.encounter],
|
|
redoStack: isUndo
|
|
? [...state.undoRedoState.redoStack, state.encounter]
|
|
: state.undoRedoState.redoStack.slice(0, -1),
|
|
},
|
|
};
|
|
}
|
|
|
|
function handleAddFromBestiary(
|
|
state: EncounterState,
|
|
entry: SearchResult,
|
|
count: number,
|
|
): EncounterState {
|
|
const { store, getEncounter } = makeStoreFromState(state);
|
|
const allEvents: DomainEvent[] = [];
|
|
let nextId = state.nextId;
|
|
let lastCId: CreatureId | null = null;
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const added = addOneFromBestiary(store, entry, nextId);
|
|
if (!added) return state;
|
|
allEvents.push(...added.events);
|
|
nextId = added.nextId;
|
|
lastCId = added.cId;
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
encounter: getEncounter(),
|
|
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
|
events: [...state.events, ...allEvents],
|
|
nextId,
|
|
lastCreatureId: lastCId,
|
|
};
|
|
}
|
|
|
|
function handleAddFromPlayerCharacter(
|
|
state: EncounterState,
|
|
pc: PlayerCharacter,
|
|
): EncounterState {
|
|
const { store, getEncounter } = makeStoreFromState(state);
|
|
const newName = resolveAndRename(store, pc.name);
|
|
const id = combatantId(`c-${state.nextId + 1}`);
|
|
const result = addCombatantUseCase(store, 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 state;
|
|
return {
|
|
...state,
|
|
encounter: getEncounter(),
|
|
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
|
events: [...state.events, ...result],
|
|
nextId: state.nextId + 1,
|
|
lastCreatureId: null,
|
|
};
|
|
}
|
|
|
|
function applyNamePrefix(
|
|
name: string,
|
|
oldAdj: "weak" | "elite" | undefined,
|
|
newAdj: "weak" | "elite" | undefined,
|
|
): string {
|
|
let base = name;
|
|
if (oldAdj === "weak" && name.startsWith("Weak ")) base = name.slice(5);
|
|
else if (oldAdj === "elite" && name.startsWith("Elite "))
|
|
base = name.slice(6);
|
|
if (newAdj === "weak") return `Weak ${base}`;
|
|
if (newAdj === "elite") return `Elite ${base}`;
|
|
return base;
|
|
}
|
|
|
|
function handleSetCreatureAdjustment(
|
|
state: EncounterState,
|
|
id: CombatantId,
|
|
adjustment: "weak" | "elite" | undefined,
|
|
baseCreature: Pf2eCreature,
|
|
): EncounterState {
|
|
const combatant = state.encounter.combatants.find((c) => c.id === id);
|
|
if (!combatant) return state;
|
|
|
|
const oldAdj = combatant.creatureAdjustment;
|
|
if (oldAdj === adjustment) return state;
|
|
|
|
const baseLevel = baseCreature.level;
|
|
const oldHpDelta = oldAdj ? hpDelta(baseLevel, oldAdj) : 0;
|
|
const newHpDelta = adjustment ? hpDelta(baseLevel, adjustment) : 0;
|
|
const netHpDelta = newHpDelta - oldHpDelta;
|
|
|
|
const oldAcDelta = oldAdj ? acDelta(oldAdj) : 0;
|
|
const newAcDelta = adjustment ? acDelta(adjustment) : 0;
|
|
const netAcDelta = newAcDelta - oldAcDelta;
|
|
|
|
const newMaxHp =
|
|
combatant.maxHp === undefined ? undefined : combatant.maxHp + netHpDelta;
|
|
const newCurrentHp =
|
|
combatant.currentHp === undefined || newMaxHp === undefined
|
|
? undefined
|
|
: Math.max(0, Math.min(combatant.currentHp + netHpDelta, newMaxHp));
|
|
const newAc =
|
|
combatant.ac === undefined ? undefined : combatant.ac + netAcDelta;
|
|
const newName = applyNamePrefix(combatant.name, oldAdj, adjustment);
|
|
|
|
const updatedCombatant: typeof combatant = {
|
|
...combatant,
|
|
name: newName,
|
|
...(newMaxHp !== undefined && { maxHp: newMaxHp }),
|
|
...(newCurrentHp !== undefined && { currentHp: newCurrentHp }),
|
|
...(newAc !== undefined && { ac: newAc }),
|
|
...(adjustment === undefined
|
|
? { creatureAdjustment: undefined }
|
|
: { creatureAdjustment: adjustment }),
|
|
};
|
|
|
|
const combatants = state.encounter.combatants.map((c) =>
|
|
c.id === id ? updatedCombatant : c,
|
|
);
|
|
|
|
return {
|
|
...state,
|
|
encounter: { ...state.encounter, combatants },
|
|
events: [
|
|
...state.events,
|
|
{ type: "CreatureAdjustmentSet", combatantId: id, adjustment },
|
|
],
|
|
};
|
|
}
|
|
|
|
// -- Reducer --
|
|
|
|
export function encounterReducer(
|
|
state: EncounterState,
|
|
action: EncounterAction,
|
|
): EncounterState {
|
|
switch (action.type) {
|
|
case "import":
|
|
return {
|
|
...state,
|
|
encounter: action.encounter,
|
|
undoRedoState: action.undoRedoState,
|
|
nextId: deriveNextId(action.encounter),
|
|
lastCreatureId: null,
|
|
};
|
|
case "undo":
|
|
case "redo":
|
|
return handleUndoRedo(state, action.type);
|
|
case "clear-encounter": {
|
|
const { store, getEncounter } = makeStoreFromState(state);
|
|
const result = clearEncounterUseCase(store);
|
|
if (isDomainError(result)) return state;
|
|
return {
|
|
...state,
|
|
encounter: getEncounter(),
|
|
undoRedoState: clearHistory(),
|
|
events: [...state.events, ...result],
|
|
nextId: 0,
|
|
lastCreatureId: null,
|
|
};
|
|
}
|
|
case "set-creature-adjustment":
|
|
return handleSetCreatureAdjustment(
|
|
state,
|
|
action.id,
|
|
action.adjustment,
|
|
action.baseCreature,
|
|
);
|
|
case "add-from-bestiary":
|
|
return handleAddFromBestiary(state, action.entry, 1);
|
|
case "add-multiple-from-bestiary":
|
|
return handleAddFromBestiary(state, action.entry, action.count);
|
|
case "add-from-player-character":
|
|
return handleAddFromPlayerCharacter(state, action.pc);
|
|
default:
|
|
return dispatchEncounterAction(state, action);
|
|
}
|
|
}
|
|
|
|
function dispatchEncounterAction(
|
|
state: EncounterState,
|
|
action: Extract<
|
|
EncounterAction,
|
|
| { type: "advance-turn" }
|
|
| { type: "retreat-turn" }
|
|
| { type: "add-combatant" }
|
|
| { type: "remove-combatant" }
|
|
| { type: "edit-combatant" }
|
|
| { type: "set-initiative" }
|
|
| { type: "set-hp" }
|
|
| { type: "adjust-hp" }
|
|
| { type: "set-temp-hp" }
|
|
| { type: "set-ac" }
|
|
| { type: "set-cr" }
|
|
| { type: "set-side" }
|
|
| { type: "toggle-condition" }
|
|
| { type: "set-condition-value" }
|
|
| { type: "decrement-condition" }
|
|
| { type: "toggle-concentration" }
|
|
| { type: "add-persistent-damage" }
|
|
| { type: "remove-persistent-damage" }
|
|
>,
|
|
): EncounterState {
|
|
const { store, getEncounter } = makeStoreFromState(state);
|
|
let result: DomainEvent[] | DomainError;
|
|
|
|
switch (action.type) {
|
|
case "advance-turn":
|
|
result = advanceTurnUseCase(store);
|
|
break;
|
|
case "retreat-turn":
|
|
result = retreatTurnUseCase(store);
|
|
break;
|
|
case "add-combatant": {
|
|
const id = combatantId(`c-${state.nextId + 1}`);
|
|
result = addCombatantUseCase(store, id, action.name, action.init);
|
|
break;
|
|
}
|
|
case "remove-combatant":
|
|
result = removeCombatantUseCase(store, action.id);
|
|
break;
|
|
case "edit-combatant":
|
|
result = editCombatantUseCase(store, action.id, action.newName);
|
|
break;
|
|
case "set-initiative":
|
|
result = setInitiativeUseCase(store, action.id, action.value);
|
|
break;
|
|
case "set-hp":
|
|
result = setHpUseCase(store, action.id, action.maxHp);
|
|
break;
|
|
case "adjust-hp":
|
|
result = adjustHpUseCase(store, action.id, action.delta);
|
|
break;
|
|
case "set-temp-hp":
|
|
result = setTempHpUseCase(store, action.id, action.tempHp);
|
|
break;
|
|
case "set-ac":
|
|
result = setAcUseCase(store, action.id, action.value);
|
|
break;
|
|
case "set-cr":
|
|
result = setCrUseCase(store, action.id, action.value);
|
|
break;
|
|
case "set-side":
|
|
result = setSideUseCase(store, action.id, action.value);
|
|
break;
|
|
case "toggle-condition":
|
|
result = toggleConditionUseCase(store, action.id, action.conditionId);
|
|
break;
|
|
case "set-condition-value":
|
|
result = setConditionValueUseCase(
|
|
store,
|
|
action.id,
|
|
action.conditionId,
|
|
action.value,
|
|
);
|
|
break;
|
|
case "decrement-condition":
|
|
result = decrementConditionUseCase(store, action.id, action.conditionId);
|
|
break;
|
|
case "toggle-concentration":
|
|
result = toggleConcentrationUseCase(store, action.id);
|
|
break;
|
|
case "add-persistent-damage":
|
|
result = addPersistentDamageUseCase(
|
|
store,
|
|
action.id,
|
|
action.damageType,
|
|
action.formula,
|
|
);
|
|
break;
|
|
case "remove-persistent-damage":
|
|
result = removePersistentDamageUseCase(
|
|
store,
|
|
action.id,
|
|
action.damageType,
|
|
);
|
|
break;
|
|
}
|
|
|
|
if (isDomainError(result)) return state;
|
|
|
|
return {
|
|
...state,
|
|
encounter: getEncounter(),
|
|
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
|
events: [...state.events, ...result],
|
|
nextId: action.type === "add-combatant" ? state.nextId + 1 : state.nextId,
|
|
lastCreatureId: null,
|
|
};
|
|
}
|
|
|
|
// -- Hook --
|
|
|
|
export function useEncounter() {
|
|
const { encounterPersistence, undoRedoPersistence } = useAdapters();
|
|
const [state, dispatch] = useReducer(encounterReducer, null, () =>
|
|
initializeState(
|
|
() => encounterPersistence.load(),
|
|
() => undoRedoPersistence.load(),
|
|
),
|
|
);
|
|
const { encounter, undoRedoState, events } = state;
|
|
|
|
const encounterRef = useRef(encounter);
|
|
encounterRef.current = encounter;
|
|
const undoRedoRef = useRef(undoRedoState);
|
|
undoRedoRef.current = undoRedoState;
|
|
|
|
useEffect(() => {
|
|
encounterPersistence.save(encounter);
|
|
}, [encounter, encounterPersistence]);
|
|
|
|
useEffect(() => {
|
|
undoRedoPersistence.save(undoRedoState);
|
|
}, [undoRedoState, undoRedoPersistence]);
|
|
|
|
// Escape hatches for useInitiativeRolls (needs raw port access)
|
|
const makeStore = useCallback((): EncounterStore => {
|
|
return {
|
|
get: () => encounterRef.current,
|
|
save: (e) => {
|
|
encounterRef.current = e;
|
|
dispatch({
|
|
type: "import",
|
|
encounter: e,
|
|
undoRedoState: undoRedoRef.current,
|
|
});
|
|
},
|
|
};
|
|
}, []);
|
|
|
|
const withUndo = useCallback(<T>(action: () => T): T => {
|
|
const snapshot = encounterRef.current;
|
|
const result = action();
|
|
if (!isDomainError(result)) {
|
|
const newState = pushUndo(undoRedoRef.current, snapshot);
|
|
undoRedoRef.current = newState;
|
|
dispatch({
|
|
type: "import",
|
|
encounter: encounterRef.current,
|
|
undoRedoState: newState,
|
|
});
|
|
}
|
|
return result;
|
|
}, []);
|
|
|
|
// Derived state
|
|
const canUndo = undoRedoState.undoStack.length > 0;
|
|
const canRedo = undoRedoState.redoStack.length > 0;
|
|
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,
|
|
undoRedoState,
|
|
events,
|
|
isEmpty,
|
|
hasTempHp,
|
|
hasCreatureCombatants,
|
|
canRollAllInitiative,
|
|
canUndo,
|
|
canRedo,
|
|
advanceTurn: useCallback(() => dispatch({ type: "advance-turn" }), []),
|
|
retreatTurn: useCallback(() => dispatch({ type: "retreat-turn" }), []),
|
|
addCombatant: useCallback(
|
|
(name: string, init?: CombatantInit) =>
|
|
dispatch({ type: "add-combatant", name, init }),
|
|
[],
|
|
),
|
|
removeCombatant: useCallback(
|
|
(id: CombatantId) => dispatch({ type: "remove-combatant", id }),
|
|
[],
|
|
),
|
|
editCombatant: useCallback(
|
|
(id: CombatantId, newName: string) =>
|
|
dispatch({ type: "edit-combatant", id, newName }),
|
|
[],
|
|
),
|
|
setInitiative: useCallback(
|
|
(id: CombatantId, value: number | undefined) =>
|
|
dispatch({ type: "set-initiative", id, value }),
|
|
[],
|
|
),
|
|
setHp: useCallback(
|
|
(id: CombatantId, maxHp: number | undefined) =>
|
|
dispatch({ type: "set-hp", id, maxHp }),
|
|
[],
|
|
),
|
|
adjustHp: useCallback(
|
|
(id: CombatantId, delta: number) =>
|
|
dispatch({ type: "adjust-hp", id, delta }),
|
|
[],
|
|
),
|
|
setTempHp: useCallback(
|
|
(id: CombatantId, tempHp: number | undefined) =>
|
|
dispatch({ type: "set-temp-hp", id, tempHp }),
|
|
[],
|
|
),
|
|
setAc: useCallback(
|
|
(id: CombatantId, value: number | undefined) =>
|
|
dispatch({ type: "set-ac", id, value }),
|
|
[],
|
|
),
|
|
setCr: useCallback(
|
|
(id: CombatantId, value: string | undefined) =>
|
|
dispatch({ type: "set-cr", id, value }),
|
|
[],
|
|
),
|
|
setSide: useCallback(
|
|
(id: CombatantId, value: "party" | "enemy") =>
|
|
dispatch({ type: "set-side", id, value }),
|
|
[],
|
|
),
|
|
toggleCondition: useCallback(
|
|
(id: CombatantId, conditionId: ConditionId) =>
|
|
dispatch({ type: "toggle-condition", id, conditionId }),
|
|
[],
|
|
),
|
|
setConditionValue: useCallback(
|
|
(id: CombatantId, conditionId: ConditionId, value: number) =>
|
|
dispatch({ type: "set-condition-value", id, conditionId, value }),
|
|
[],
|
|
),
|
|
decrementCondition: useCallback(
|
|
(id: CombatantId, conditionId: ConditionId) =>
|
|
dispatch({ type: "decrement-condition", id, conditionId }),
|
|
[],
|
|
),
|
|
toggleConcentration: useCallback(
|
|
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
|
[],
|
|
),
|
|
addPersistentDamage: useCallback(
|
|
(id: CombatantId, damageType: PersistentDamageType, formula: string) =>
|
|
dispatch({ type: "add-persistent-damage", id, damageType, formula }),
|
|
[],
|
|
),
|
|
removePersistentDamage: useCallback(
|
|
(id: CombatantId, damageType: PersistentDamageType) =>
|
|
dispatch({ type: "remove-persistent-damage", id, damageType }),
|
|
[],
|
|
),
|
|
setCreatureAdjustment: useCallback(
|
|
(
|
|
id: CombatantId,
|
|
adjustment: "weak" | "elite" | undefined,
|
|
baseCreature: Pf2eCreature,
|
|
) =>
|
|
dispatch({
|
|
type: "set-creature-adjustment",
|
|
id,
|
|
adjustment,
|
|
baseCreature,
|
|
}),
|
|
[],
|
|
),
|
|
clearEncounter: useCallback(
|
|
() => dispatch({ type: "clear-encounter" }),
|
|
[],
|
|
),
|
|
addFromBestiary: useCallback((entry: SearchResult): CreatureId | null => {
|
|
dispatch({ type: "add-from-bestiary", entry });
|
|
return null;
|
|
}, []),
|
|
addMultipleFromBestiary: useCallback(
|
|
(entry: SearchResult, count: number): CreatureId | null => {
|
|
dispatch({
|
|
type: "add-multiple-from-bestiary",
|
|
entry,
|
|
count,
|
|
});
|
|
return null;
|
|
},
|
|
[],
|
|
),
|
|
addFromPlayerCharacter: useCallback(
|
|
(pc: PlayerCharacter) =>
|
|
dispatch({ type: "add-from-player-character", pc }),
|
|
[],
|
|
),
|
|
undo: useCallback(() => dispatch({ type: "undo" }), []),
|
|
redo: useCallback(() => dispatch({ type: "redo" }), []),
|
|
setEncounter: useCallback(
|
|
(enc: Encounter) =>
|
|
dispatch({
|
|
type: "import",
|
|
encounter: enc,
|
|
undoRedoState: undoRedoRef.current,
|
|
}),
|
|
[],
|
|
),
|
|
setUndoRedoState: useCallback(
|
|
(urs: UndoRedoState) =>
|
|
dispatch({
|
|
type: "import",
|
|
encounter: encounterRef.current,
|
|
undoRedoState: urs,
|
|
}),
|
|
[],
|
|
),
|
|
makeStore,
|
|
withUndo,
|
|
lastCreatureId: state.lastCreatureId,
|
|
} as const;
|
|
}
|