Files
initiative/apps/web/src/hooks/use-encounter.ts

316 lines
6.9 KiB
TypeScript

import type { EncounterStore } from "@initiative/application";
import {
addCombatantUseCase,
adjustHpUseCase,
advanceTurnUseCase,
clearEncounterUseCase,
editCombatantUseCase,
removeCombatantUseCase,
retreatTurnUseCase,
setAcUseCase,
setHpUseCase,
setInitiativeUseCase,
toggleConcentrationUseCase,
toggleConditionUseCase,
} from "@initiative/application";
import type {
BestiaryIndexEntry,
CombatantId,
ConditionId,
DomainEvent,
Encounter,
} from "@initiative/domain";
import {
combatantId,
createEncounter,
isDomainError,
creatureId as makeCreatureId,
resolveCreatureName,
} from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import {
loadEncounter,
saveEncounter,
} from "../persistence/encounter-storage.js";
function createDemoEncounter(): Encounter {
const result = createEncounter([
{ id: combatantId("1"), name: "Aria" },
{ id: combatantId("2"), name: "Brak" },
{ id: combatantId("3"), name: "Cael" },
]);
if (isDomainError(result)) {
throw new Error(`Failed to create demo encounter: ${result.message}`);
}
return result;
}
function initializeEncounter(): Encounter {
const stored = loadEncounter();
if (stored !== null) return stored;
return createDemoEncounter();
}
function deriveNextId(encounter: Encounter): number {
let max = 0;
for (const c of encounter.combatants) {
const match = /^c-(\d+)$/.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) => {
const id = combatantId(`c-${++nextId.current}`);
const result = addCombatantUseCase(makeStore(), id, name);
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 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) => {
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;
// 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()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
// Set creatureId on the combatant
const currentEncounter = store.get();
const updated = {
...currentEncounter,
combatants: currentEncounter.combatants.map((c) =>
c.id === id ? { ...c, creatureId: cId } : c,
),
};
setEncounter(updated);
setEvents((prev) => [...prev, ...addResult]);
},
[makeStore, editCombatant],
);
return {
encounter,
events,
advanceTurn,
retreatTurn,
addCombatant,
clearEncounter,
removeCombatant,
editCombatant,
setInitiative,
setHp,
adjustHp,
setAc,
toggleCondition,
toggleConcentration,
addFromBestiary,
makeStore,
} as const;
}