addCombatant now accepts an optional init parameter for pre-filled stats (HP, AC, initiative, creatureId, color, icon, playerCharacterId), making combatant creation a single atomic operation with domain validation. This eliminates the multi-step store.save() bypass in addFromBestiary and addFromPlayerCharacter, and removes the CombatantOpts/applyCombatantOpts helpers. Also extracts shared initiative sort logic into initiative-sort.ts used by both addCombatant and setInitiative. Closes #15 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
145 lines
3.6 KiB
TypeScript
145 lines
3.6 KiB
TypeScript
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,
|
|
},
|
|
],
|
|
};
|
|
}
|