Implements PF2e as an alternative game system alongside D&D 5e/5.5e. Settings modal "Game System" selector switches conditions, bestiary, stat block layout, and initiative calculation between systems. - Valued conditions with increment/decrement UX (Clumsy 2, Frightened 3) - 2,502 PF2e creatures from bundled search index (77 sources) - PF2e stat block: level, traits, Perception, Fort/Ref/Will, ability mods - Perception-based initiative rolling - System-scoped source cache (D&D and PF2e sources don't collide) - Backwards-compatible condition rehydration (ConditionId[] → ConditionEntry[]) - Difficulty indicator hidden in PF2e mode (excluded from MVP) Closes dostulata/initiative#19 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
614 lines
16 KiB
TypeScript
614 lines
16 KiB
TypeScript
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
|
|
import {
|
|
addCombatantUseCase,
|
|
adjustHpUseCase,
|
|
advanceTurnUseCase,
|
|
clearEncounterUseCase,
|
|
decrementConditionUseCase,
|
|
editCombatantUseCase,
|
|
redoUseCase,
|
|
removeCombatantUseCase,
|
|
retreatTurnUseCase,
|
|
setAcUseCase,
|
|
setConditionValueUseCase,
|
|
setCrUseCase,
|
|
setHpUseCase,
|
|
setInitiativeUseCase,
|
|
setSideUseCase,
|
|
setTempHpUseCase,
|
|
toggleConcentrationUseCase,
|
|
toggleConditionUseCase,
|
|
undoUseCase,
|
|
} from "@initiative/application";
|
|
import type {
|
|
CombatantId,
|
|
CombatantInit,
|
|
ConditionId,
|
|
CreatureId,
|
|
DomainError,
|
|
DomainEvent,
|
|
Encounter,
|
|
PlayerCharacter,
|
|
UndoRedoState,
|
|
} from "@initiative/domain";
|
|
import {
|
|
clearHistory,
|
|
combatantId,
|
|
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: "clear-encounter" }
|
|
| { type: "undo" }
|
|
| { type: "redo" }
|
|
| { type: "add-from-bestiary"; entry: SearchResult }
|
|
| {
|
|
type: "add-multiple-from-bestiary";
|
|
entry: SearchResult;
|
|
count: number;
|
|
}
|
|
| { 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,
|
|
};
|
|
}
|
|
|
|
// -- 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 "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" }
|
|
>,
|
|
): 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;
|
|
}
|
|
|
|
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 }),
|
|
[],
|
|
),
|
|
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;
|
|
}
|