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>
352 lines
9.6 KiB
TypeScript
352 lines
9.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
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,
|
|
overrides?: Partial<Combatant>,
|
|
): Combatant {
|
|
return { id: combatantId(name), name, ...overrides };
|
|
}
|
|
|
|
const A = makeCombatant("A");
|
|
const B = makeCombatant("B");
|
|
const C = makeCombatant("C");
|
|
|
|
function enc(
|
|
combatants: Combatant[],
|
|
activeIndex = 0,
|
|
roundNumber = 1,
|
|
): Encounter {
|
|
return { combatants, activeIndex, roundNumber };
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// --- Acceptance Scenarios ---
|
|
|
|
describe("addCombatant", () => {
|
|
describe("acceptance scenarios", () => {
|
|
it("scenario 1: add to empty encounter", () => {
|
|
const e = enc([], 0, 1);
|
|
const { encounter, events } = successResult(e, "gandalf", "Gandalf");
|
|
|
|
expect(encounter.combatants).toEqual([
|
|
{ id: combatantId("gandalf"), name: "Gandalf" },
|
|
]);
|
|
expect(encounter.activeIndex).toBe(0);
|
|
expect(encounter.roundNumber).toBe(1);
|
|
expect(events).toEqual([
|
|
{
|
|
type: "CombatantAdded",
|
|
combatantId: combatantId("gandalf"),
|
|
name: "Gandalf",
|
|
position: 0,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("scenario 2: add to encounter with [A, B]", () => {
|
|
const e = enc([A, B], 0, 1);
|
|
const { encounter, events } = successResult(e, "C", "C");
|
|
|
|
expect(encounter.combatants).toEqual([
|
|
A,
|
|
B,
|
|
{ id: combatantId("C"), name: "C" },
|
|
]);
|
|
expect(encounter.activeIndex).toBe(0);
|
|
expect(encounter.roundNumber).toBe(1);
|
|
expect(events).toEqual([
|
|
{
|
|
type: "CombatantAdded",
|
|
combatantId: combatantId("C"),
|
|
name: "C",
|
|
position: 2,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("scenario 3: add during mid-round does not change active combatant", () => {
|
|
const e = enc([A, B, C], 2, 3);
|
|
const { encounter, events } = successResult(e, "D", "D");
|
|
|
|
expect(encounter.combatants).toHaveLength(4);
|
|
expect(encounter.combatants[3]).toEqual({
|
|
id: combatantId("D"),
|
|
name: "D",
|
|
});
|
|
expect(encounter.activeIndex).toBe(2);
|
|
expect(encounter.roundNumber).toBe(3);
|
|
expect(events).toEqual([
|
|
{
|
|
type: "CombatantAdded",
|
|
combatantId: combatantId("D"),
|
|
name: "D",
|
|
position: 3,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("scenario 4: two sequential adds preserve order", () => {
|
|
const e = enc([A]);
|
|
const first = successResult(e, "B", "B");
|
|
const second = successResult(first.encounter, "C", "C");
|
|
|
|
expect(second.encounter.combatants).toEqual([
|
|
A,
|
|
{ id: combatantId("B"), name: "B" },
|
|
{ id: combatantId("C"), name: "C" },
|
|
]);
|
|
expect(first.events).toHaveLength(1);
|
|
expect(second.events).toHaveLength(1);
|
|
});
|
|
|
|
it("scenario 5: empty name returns error", () => {
|
|
const e = enc([A, B]);
|
|
const result = addCombatant(e, combatantId("x"), "");
|
|
|
|
expectDomainError(result, "invalid-name");
|
|
});
|
|
|
|
it("scenario 6: whitespace-only name returns error", () => {
|
|
const e = enc([A, B]);
|
|
const result = addCombatant(e, combatantId("x"), " ");
|
|
|
|
expectDomainError(result, "invalid-name");
|
|
});
|
|
});
|
|
|
|
describe("invariants", () => {
|
|
it("INV-1: encounter may have zero combatants (adding to empty is valid)", () => {
|
|
const e = enc([]);
|
|
const result = addCombatant(e, combatantId("a"), "A");
|
|
expect(isDomainError(result)).toBe(false);
|
|
});
|
|
|
|
it("INV-2: activeIndex remains valid after adding", () => {
|
|
const scenarios: Encounter[] = [
|
|
enc([], 0, 1),
|
|
enc([A], 0, 1),
|
|
enc([A, B, C], 2, 3),
|
|
];
|
|
|
|
for (const e of scenarios) {
|
|
const result = successResult(e, "new", "New");
|
|
const { combatants, activeIndex } = result.encounter;
|
|
// After adding a combatant, list is always non-empty
|
|
expect(combatants.length).toBeGreaterThan(0);
|
|
expect(activeIndex).toBeGreaterThanOrEqual(0);
|
|
expect(activeIndex).toBeLessThan(combatants.length);
|
|
}
|
|
});
|
|
|
|
it("INV-3: roundNumber is preserved (never decreases)", () => {
|
|
const e = enc([A, B], 1, 5);
|
|
const { encounter } = successResult(e, "C", "C");
|
|
expect(encounter.roundNumber).toBe(5);
|
|
});
|
|
|
|
it("INV-4: determinism — same input produces same output", () => {
|
|
const e = enc([A, B], 1, 3);
|
|
const result1 = addCombatant(e, combatantId("x"), "X");
|
|
const result2 = addCombatant(e, combatantId("x"), "X");
|
|
expect(result1).toEqual(result2);
|
|
});
|
|
|
|
it("INV-5: every success emits exactly one CombatantAdded event", () => {
|
|
const scenarios: Encounter[] = [enc([]), enc([A]), enc([A, B, C], 2, 5)];
|
|
|
|
for (const e of scenarios) {
|
|
const result = successResult(e, "z", "Z");
|
|
expect(result.events).toHaveLength(1);
|
|
expect(result.events[0].type).toBe("CombatantAdded");
|
|
}
|
|
});
|
|
|
|
it("INV-6: addCombatant does not change activeIndex or roundNumber", () => {
|
|
const e = enc([A, B, C], 2, 7);
|
|
const { encounter } = successResult(e, "D", "D");
|
|
expect(encounter.activeIndex).toBe(2);
|
|
expect(encounter.roundNumber).toBe(7);
|
|
});
|
|
|
|
it("INV-7: new combatant is always appended at the end", () => {
|
|
const e = enc([A, B]);
|
|
const { encounter } = successResult(e, "C", "C");
|
|
expect(encounter.combatants.at(-1)).toEqual({
|
|
id: combatantId("C"),
|
|
name: "C",
|
|
});
|
|
// Existing combatants preserve order
|
|
expect(encounter.combatants[0]).toEqual(A);
|
|
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);
|
|
});
|
|
});
|
|
});
|