316 lines
6.9 KiB
TypeScript
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;
|
|
}
|