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,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;

View File

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

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

View File

@@ -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 {

View File

@@ -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";

View File

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

View File

@@ -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: {