import type { CreatureId } from "./creature-types.js"; import type { DomainEvent } from "./events.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 (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(); if (trimmed === "") { return { kind: "domain-error", code: "invalid-name", message: "Combatant name must not be empty", }; } 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, activeIndex, roundNumber: encounter.roundNumber, }, events: [ { type: "CombatantAdded", combatantId: id, name: trimmed, position, init, }, ], }; }