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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user