Implement the 002-add-combatant feature that adds the possibility to add new combatants to an encounter
This commit is contained in:
200
packages/domain/src/__tests__/add-combatant.test.ts
Normal file
200
packages/domain/src/__tests__/add-combatant.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addCombatant } from "../add-combatant.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeCombatant(name: string): Combatant {
|
||||
return { id: combatantId(name), name };
|
||||
}
|
||||
|
||||
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) {
|
||||
const result = addCombatant(encounter, combatantId(id), name);
|
||||
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"), "");
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-name");
|
||||
}
|
||||
});
|
||||
|
||||
it("scenario 6: whitespace-only name returns error", () => {
|
||||
const e = enc([A, B]);
|
||||
const result = addCombatant(e, combatantId("x"), " ");
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("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;
|
||||
if (combatants.length > 0) {
|
||||
expect(activeIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(activeIndex).toBeLessThan(combatants.length);
|
||||
} else {
|
||||
expect(activeIndex).toBe(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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[encounter.combatants.length - 1]).toEqual({
|
||||
id: combatantId("C"),
|
||||
name: "C",
|
||||
});
|
||||
// Existing combatants preserve order
|
||||
expect(encounter.combatants[0]).toEqual(A);
|
||||
expect(encounter.combatants[1]).toEqual(B);
|
||||
});
|
||||
});
|
||||
});
|
||||
50
packages/domain/src/add-combatant.ts
Normal file
50
packages/domain/src/add-combatant.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||
|
||||
export interface AddCombatantSuccess {
|
||||
readonly encounter: Encounter;
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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-006: Events returned as values, not dispatched via side effects.
|
||||
*/
|
||||
export function addCombatant(
|
||||
encounter: Encounter,
|
||||
id: CombatantId,
|
||||
name: string,
|
||||
): AddCombatantSuccess | DomainError {
|
||||
const trimmed = name.trim();
|
||||
|
||||
if (trimmed === "") {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-name",
|
||||
message: "Combatant name must not be empty",
|
||||
};
|
||||
}
|
||||
|
||||
const position = encounter.combatants.length;
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
combatants: [...encounter.combatants, { id, name: trimmed }],
|
||||
activeIndex: encounter.activeIndex,
|
||||
roundNumber: encounter.roundNumber,
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: "CombatantAdded",
|
||||
combatantId: id,
|
||||
name: trimmed,
|
||||
position,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -12,4 +12,11 @@ export interface RoundAdvanced {
|
||||
readonly newRoundNumber: number;
|
||||
}
|
||||
|
||||
export type DomainEvent = TurnAdvanced | RoundAdvanced;
|
||||
export interface CombatantAdded {
|
||||
readonly type: "CombatantAdded";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly name: string;
|
||||
readonly position: number;
|
||||
}
|
||||
|
||||
export type DomainEvent = TurnAdvanced | RoundAdvanced | CombatantAdded;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js";
|
||||
export { advanceTurn } from "./advance-turn.js";
|
||||
|
||||
export type {
|
||||
CombatantAdded,
|
||||
DomainEvent,
|
||||
RoundAdvanced,
|
||||
TurnAdvanced,
|
||||
|
||||
Reference in New Issue
Block a user