Atomic addCombatant with optional CombatantInit bag

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>
This commit is contained in:
Lukas
2026-03-26 22:13:20 +01:00
parent 7199b9d2d9
commit 9d81c8ad27
8 changed files with 344 additions and 142 deletions

View File

@@ -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,
},
],
};