diff --git a/apps/web/src/hooks/use-encounter.ts b/apps/web/src/hooks/use-encounter.ts index 0a23967..fa7b513 100644 --- a/apps/web/src/hooks/use-encounter.ts +++ b/apps/web/src/hooks/use-encounter.ts @@ -17,6 +17,7 @@ import { import type { BestiaryIndexEntry, CombatantId, + CombatantInit, ConditionId, CreatureId, DomainEvent, @@ -61,33 +62,6 @@ function deriveNextId(encounter: Encounter): number { return max; } -interface CombatantOpts { - initiative?: number; - ac?: number; - maxHp?: number; -} - -function applyCombatantOpts( - makeStore: () => EncounterStore, - id: ReturnType, - 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(initializeEncounter); const [events, setEvents] = useState([]); @@ -131,21 +105,14 @@ export function useEncounter() { const nextId = useRef(deriveNextId(encounter)); const addCombatant = useCallback( - (name: string, opts?: CombatantOpts) => { + (name: string, init?: CombatantInit) => { const id = combatantId(`c-${++nextId.current}`); - const result = addCombatantUseCase(makeStore(), id, name); + const result = addCombatantUseCase(makeStore(), id, name, init); 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], @@ -288,7 +255,6 @@ export function useEncounter() { 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) { @@ -296,43 +262,22 @@ export function useEncounter() { } } - // 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, - ), + const id = combatantId(`c-${++nextId.current}`); + const result = addCombatantUseCase(makeStore(), id, newName, { + maxHp: entry.hp, + ac: entry.ac > 0 ? entry.ac : undefined, + creatureId: cId, }); - setEvents((prev) => [...prev, ...addResult]); + if (isDomainError(result)) return null; + setEvents((prev) => [...prev, ...result]); return cId; }, [makeStore], @@ -352,40 +297,17 @@ export function useEncounter() { } 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, - ), + const result = addCombatantUseCase(makeStore(), id, newName, { + maxHp: pc.maxHp, + ac: pc.ac > 0 ? pc.ac : undefined, + color: pc.color, + icon: pc.icon, + playerCharacterId: pc.id, }); - setEvents((prev) => [...prev, ...addResult]); + if (isDomainError(result)) return; + + setEvents((prev) => [...prev, ...result]); }, [makeStore], ); diff --git a/packages/application/src/add-combatant-use-case.ts b/packages/application/src/add-combatant-use-case.ts index b47f47b..8f14e7a 100644 --- a/packages/application/src/add-combatant-use-case.ts +++ b/packages/application/src/add-combatant-use-case.ts @@ -1,6 +1,7 @@ import { addCombatant, type CombatantId, + type CombatantInit, type DomainError, type DomainEvent, isDomainError, @@ -11,9 +12,10 @@ export function addCombatantUseCase( store: EncounterStore, id: CombatantId, name: string, + init?: CombatantInit, ): DomainEvent[] | DomainError { const encounter = store.get(); - const result = addCombatant(encounter, id, name); + const result = addCombatant(encounter, id, name, init); if (isDomainError(result)) { return result; diff --git a/packages/domain/src/__tests__/add-combatant.test.ts b/packages/domain/src/__tests__/add-combatant.test.ts index 8a33bda..6a4a88c 100644 --- a/packages/domain/src/__tests__/add-combatant.test.ts +++ b/packages/domain/src/__tests__/add-combatant.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it } from "vitest"; -import { addCombatant } from "../add-combatant.js"; +import { addCombatant, type CombatantInit } from "../add-combatant.js"; +import { creatureId } from "../creature-types.js"; +import { playerCharacterId } from "../player-character-types.js"; import type { Combatant, Encounter } from "../types.js"; import { combatantId, isDomainError } from "../types.js"; import { expectDomainError } from "./test-helpers.js"; // --- Helpers --- -function makeCombatant(name: string): Combatant { - return { id: combatantId(name), name }; +function makeCombatant( + name: string, + overrides?: Partial, +): Combatant { + return { id: combatantId(name), name, ...overrides }; } const A = makeCombatant("A"); @@ -22,8 +27,13 @@ function enc( return { combatants, activeIndex, roundNumber }; } -function successResult(encounter: Encounter, id: string, name: string) { - const result = addCombatant(encounter, combatantId(id), name); +function successResult( + encounter: Encounter, + id: string, + name: string, + init?: CombatantInit, +) { + const result = addCombatant(encounter, combatantId(id), name, init); if (isDomainError(result)) { throw new Error(`Expected success, got error: ${result.message}`); } @@ -190,4 +200,152 @@ describe("addCombatant", () => { expect(encounter.combatants[1]).toEqual(B); }); }); + + describe("with CombatantInit", () => { + it("creates combatant with maxHp and currentHp set to maxHp", () => { + const e = enc([]); + const { encounter } = successResult(e, "orc", "Orc", { + maxHp: 15, + }); + const c = encounter.combatants[0]; + expect(c.maxHp).toBe(15); + expect(c.currentHp).toBe(15); + }); + + it("creates combatant with ac", () => { + const e = enc([]); + const { encounter } = successResult(e, "orc", "Orc", { + ac: 13, + }); + expect(encounter.combatants[0].ac).toBe(13); + }); + + it("creates combatant with initiative and sorts into position", () => { + const hi = makeCombatant("Hi", { initiative: 20 }); + const lo = makeCombatant("Lo", { initiative: 10 }); + const e = enc([hi, lo]); + + const { encounter } = successResult(e, "mid", "Mid", { + initiative: 15, + }); + + expect(encounter.combatants.map((c) => c.name)).toEqual([ + "Hi", + "Mid", + "Lo", + ]); + }); + + it("rejects invalid maxHp (non-integer)", () => { + const e = enc([]); + const result = addCombatant(e, combatantId("x"), "X", { + maxHp: 1.5, + }); + expectDomainError(result, "invalid-max-hp"); + }); + + it("rejects invalid maxHp (zero)", () => { + const e = enc([]); + const result = addCombatant(e, combatantId("x"), "X", { + maxHp: 0, + }); + expectDomainError(result, "invalid-max-hp"); + }); + + it("rejects invalid ac (negative)", () => { + const e = enc([]); + const result = addCombatant(e, combatantId("x"), "X", { + ac: -1, + }); + expectDomainError(result, "invalid-ac"); + }); + + it("rejects invalid initiative (non-integer)", () => { + const e = enc([]); + const result = addCombatant(e, combatantId("x"), "X", { + initiative: 3.5, + }); + expectDomainError(result, "invalid-initiative"); + }); + + it("creates combatant with creatureId", () => { + const e = enc([]); + const cId = creatureId("srd:goblin"); + const { encounter } = successResult(e, "gob", "Goblin", { + creatureId: cId, + }); + expect(encounter.combatants[0].creatureId).toBe(cId); + }); + + it("creates combatant with color and icon", () => { + const e = enc([]); + const { encounter } = successResult(e, "pc", "Aria", { + color: "blue", + icon: "sword", + }); + const c = encounter.combatants[0]; + expect(c.color).toBe("blue"); + expect(c.icon).toBe("sword"); + }); + + it("creates combatant with playerCharacterId", () => { + const e = enc([]); + const pcId = playerCharacterId("pc-1"); + const { encounter } = successResult(e, "pc", "Aria", { + playerCharacterId: pcId, + }); + expect(encounter.combatants[0].playerCharacterId).toBe(pcId); + }); + + it("creates combatant with all init fields", () => { + const e = enc([]); + const cId = creatureId("srd:orc"); + const pcId = playerCharacterId("pc-1"); + const { encounter } = successResult(e, "orc", "Orc", { + maxHp: 15, + ac: 13, + initiative: 12, + creatureId: cId, + color: "red", + icon: "axe", + playerCharacterId: pcId, + }); + const c = encounter.combatants[0]; + expect(c.maxHp).toBe(15); + expect(c.currentHp).toBe(15); + expect(c.ac).toBe(13); + expect(c.initiative).toBe(12); + expect(c.creatureId).toBe(cId); + expect(c.color).toBe("red"); + expect(c.icon).toBe("axe"); + expect(c.playerCharacterId).toBe(pcId); + }); + + it("CombatantAdded event includes init", () => { + const e = enc([]); + const { events } = successResult(e, "orc", "Orc", { + maxHp: 15, + ac: 13, + }); + expect(events[0]).toMatchObject({ + type: "CombatantAdded", + init: { maxHp: 15, ac: 13 }, + }); + }); + + it("preserves activeIndex through initiative sort", () => { + const hi = makeCombatant("Hi", { initiative: 20 }); + const lo = makeCombatant("Lo", { initiative: 10 }); + // Lo is active (index 1) + const e = enc([hi, lo], 1); + + const { encounter } = successResult(e, "mid", "Mid", { + initiative: 15, + }); + + // Lo should still be active after sort + const loIdx = encounter.combatants.findIndex((c) => c.name === "Lo"); + expect(encounter.activeIndex).toBe(loIdx); + }); + }); }); diff --git a/packages/domain/src/add-combatant.ts b/packages/domain/src/add-combatant.ts index 06e1434..a2bec44 100644 --- a/packages/domain/src/add-combatant.ts +++ b/packages/domain/src/add-combatant.ts @@ -1,24 +1,94 @@ +import type { CreatureId } from "./creature-types.js"; import type { DomainEvent } from "./events.js"; -import type { CombatantId, DomainError, Encounter } from "./types.js"; +import { sortByInitiative } from "./initiative-sort.js"; +import type { PlayerCharacterId } from "./player-character-types.js"; +import type { + Combatant, + CombatantId, + DomainError, + Encounter, +} from "./types.js"; + +export interface CombatantInit { + readonly maxHp?: number; + readonly ac?: number; + readonly initiative?: number; + readonly creatureId?: CreatureId; + readonly color?: string; + readonly icon?: string; + readonly playerCharacterId?: PlayerCharacterId; +} export interface AddCombatantSuccess { readonly encounter: Encounter; readonly events: DomainEvent[]; } +function validateInit(init: CombatantInit): DomainError | undefined { + if ( + init.maxHp !== undefined && + (!Number.isInteger(init.maxHp) || init.maxHp < 1) + ) { + return { + kind: "domain-error", + code: "invalid-max-hp", + message: `Max HP must be a positive integer, got ${init.maxHp}`, + }; + } + if (init.ac !== undefined && (!Number.isInteger(init.ac) || init.ac < 0)) { + return { + kind: "domain-error", + code: "invalid-ac", + message: `AC must be a non-negative integer, got ${init.ac}`, + }; + } + if (init.initiative !== undefined && !Number.isInteger(init.initiative)) { + return { + kind: "domain-error", + code: "invalid-initiative", + message: `Initiative must be an integer, got ${init.initiative}`, + }; + } + return undefined; +} + +function buildCombatant( + id: CombatantId, + name: string, + init?: CombatantInit, +): Combatant { + return { + id, + name, + ...(init?.maxHp !== undefined && { + maxHp: init.maxHp, + currentHp: init.maxHp, + }), + ...(init?.ac !== undefined && { ac: init.ac }), + ...(init?.initiative !== undefined && { initiative: init.initiative }), + ...(init?.creatureId !== undefined && { creatureId: init.creatureId }), + ...(init?.color !== undefined && { color: init.color }), + ...(init?.icon !== undefined && { icon: init.icon }), + ...(init?.playerCharacterId !== undefined && { + playerCharacterId: init.playerCharacterId, + }), + }; +} + /** * Pure function that adds a combatant to the end of an encounter's list. * * FR-001: Accepts an Encounter, CombatantId, and name; returns next state + events. * FR-002: Appends new combatant to end of combatants list. * FR-004: Rejects empty/whitespace-only names with DomainError. - * FR-005: Does not alter activeIndex or roundNumber. + * FR-005: Does not alter activeIndex or roundNumber (unless initiative triggers sort). * FR-006: Events returned as values, not dispatched via side effects. */ export function addCombatant( encounter: Encounter, id: CombatantId, name: string, + init?: CombatantInit, ): AddCombatantSuccess | DomainError { const trimmed = name.trim(); @@ -30,12 +100,35 @@ export function addCombatant( }; } - const position = encounter.combatants.length; + if (init) { + const error = validateInit(init); + if (error) return error; + } + + const newCombatant = buildCombatant(id, trimmed, init); + let combatants: readonly Combatant[] = [ + ...encounter.combatants, + newCombatant, + ]; + let activeIndex = encounter.activeIndex; + + if (init?.initiative !== undefined) { + const activeCombatantId = + encounter.combatants.length > 0 + ? encounter.combatants[encounter.activeIndex].id + : id; + + const result = sortByInitiative(combatants, activeCombatantId); + combatants = result.sorted; + activeIndex = result.activeIndex; + } + + const position = combatants.findIndex((c) => c.id === id); return { encounter: { - combatants: [...encounter.combatants, { id, name: trimmed }], - activeIndex: encounter.activeIndex, + combatants, + activeIndex, roundNumber: encounter.roundNumber, }, events: [ @@ -44,6 +137,7 @@ export function addCombatant( combatantId: id, name: trimmed, position, + init, }, ], }; diff --git a/packages/domain/src/events.ts b/packages/domain/src/events.ts index 1e0dac8..bfff32c 100644 --- a/packages/domain/src/events.ts +++ b/packages/domain/src/events.ts @@ -1,4 +1,5 @@ import type { ConditionId } from "./conditions.js"; +import type { CreatureId } from "./creature-types.js"; import type { PlayerCharacterId } from "./player-character-types.js"; import type { CombatantId } from "./types.js"; @@ -19,6 +20,15 @@ export interface CombatantAdded { readonly combatantId: CombatantId; readonly name: string; readonly position: number; + readonly init?: { + readonly maxHp?: number; + readonly ac?: number; + readonly initiative?: number; + readonly creatureId?: CreatureId; + readonly color?: string; + readonly icon?: string; + readonly playerCharacterId?: PlayerCharacterId; + }; } export interface CombatantRemoved { diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 1c9496e..dca7146 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -1,4 +1,8 @@ -export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js"; +export { + type AddCombatantSuccess, + addCombatant, + type CombatantInit, +} from "./add-combatant.js"; export { type AdjustHpSuccess, adjustHp } from "./adjust-hp.js"; export { advanceTurn } from "./advance-turn.js"; export { resolveCreatureName } from "./auto-number.js"; diff --git a/packages/domain/src/initiative-sort.ts b/packages/domain/src/initiative-sort.ts new file mode 100644 index 0000000..cfd4de9 --- /dev/null +++ b/packages/domain/src/initiative-sort.ts @@ -0,0 +1,35 @@ +import type { Combatant, CombatantId } from "./types.js"; + +interface Indexed { + readonly c: Combatant; + readonly i: number; +} + +function compareByInitiative(a: Indexed, b: Indexed): number { + const aHas = a.c.initiative !== undefined; + const bHas = b.c.initiative !== undefined; + if (aHas && bHas) { + const diff = b.c.initiative - a.c.initiative; + return diff === 0 ? a.i - b.i : diff; + } + if (aHas && !bHas) return -1; + if (!aHas && bHas) return 1; + return a.i - b.i; +} + +/** + * Stable-sorts combatants by initiative (descending), preserving relative + * order for ties and for combatants without initiative. Returns the sorted + * list and the new activeIndex that tracks the given active combatant + * through the reorder. + */ +export function sortByInitiative( + combatants: readonly Combatant[], + activeCombatantId: CombatantId, +): { sorted: readonly Combatant[]; activeIndex: number } { + const indexed = combatants.map((c, i) => ({ c, i })); + indexed.sort(compareByInitiative); + const sorted = indexed.map(({ c }) => c); + const idx = sorted.findIndex((c) => c.id === activeCombatantId); + return { sorted, activeIndex: idx === -1 ? 0 : idx }; +} diff --git a/packages/domain/src/set-initiative.ts b/packages/domain/src/set-initiative.ts index 5796d9f..539af76 100644 --- a/packages/domain/src/set-initiative.ts +++ b/packages/domain/src/set-initiative.ts @@ -1,4 +1,5 @@ import type { DomainEvent } from "./events.js"; +import { sortByInitiative } from "./initiative-sort.js"; import type { CombatantId, DomainError, Encounter } from "./types.js"; export interface SetInitiativeSuccess { @@ -44,45 +45,21 @@ export function setInitiative( const target = encounter.combatants[targetIdx]; const previousValue = target.initiative; - // Record active combatant's id before reorder - const activeCombatantId = - encounter.combatants.length > 0 - ? encounter.combatants[encounter.activeIndex].id - : undefined; - // Create new combatants array with updated initiative const updated = encounter.combatants.map((c) => c.id === combatantId ? { ...c, initiative: value } : c, ); - // Stable sort: initiative descending, undefined last - const indexed = updated.map((c, i) => ({ c, i })); - indexed.sort((a, b) => { - const aHas = a.c.initiative !== undefined; - const bHas = b.c.initiative !== undefined; + // Record active combatant's id before reorder + const activeCombatantId = + encounter.combatants.length > 0 + ? encounter.combatants[encounter.activeIndex].id + : combatantId; - if (aHas && bHas) { - const aInit = a.c.initiative as number; - const bInit = b.c.initiative as number; - const diff = bInit - aInit; - return diff === 0 ? a.i - b.i : diff; - } - if (aHas && !bHas) return -1; - if (!aHas && bHas) return 1; - // Both undefined — preserve relative order - return a.i - b.i; - }); - - const sorted = indexed.map(({ c }) => c); - - // Find active combatant's new index - let newActiveIndex = encounter.activeIndex; - if (activeCombatantId !== undefined) { - const idx = sorted.findIndex((c) => c.id === activeCombatantId); - if (idx !== -1) { - newActiveIndex = idx; - } - } + const { sorted, activeIndex: newActiveIndex } = sortByInitiative( + updated, + activeCombatantId, + ); return { encounter: {