Implement the 005-set-initiative feature that adds initiative values to combatants with automatic descending sort and active turn preservation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
314
packages/domain/src/__tests__/set-initiative.test.ts
Normal file
314
packages/domain/src/__tests__/set-initiative.test.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setInitiative } from "../set-initiative.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeCombatant(name: string, initiative?: number): Combatant {
|
||||
return initiative === undefined
|
||||
? { id: combatantId(name), name }
|
||||
: { id: combatantId(name), name, initiative };
|
||||
}
|
||||
|
||||
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,
|
||||
value: number | undefined,
|
||||
) {
|
||||
const result = setInitiative(encounter, combatantId(id), value);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function names(encounter: Encounter): string[] {
|
||||
return encounter.combatants.map((c) => c.name);
|
||||
}
|
||||
|
||||
// --- US1: Set Initiative ---
|
||||
|
||||
describe("setInitiative", () => {
|
||||
describe("US1: set initiative value", () => {
|
||||
it("AS-1: set initiative on combatant with no initiative", () => {
|
||||
const e = enc([A, B], 0);
|
||||
const { encounter, events } = successResult(e, "A", 15);
|
||||
|
||||
expect(encounter.combatants[0].initiative).toBe(15);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "InitiativeSet",
|
||||
combatantId: combatantId("A"),
|
||||
previousValue: undefined,
|
||||
newValue: 15,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("AS-2: change existing initiative value", () => {
|
||||
const e = enc([makeCombatant("A", 15), B], 0);
|
||||
const { encounter, events } = successResult(e, "A", 8);
|
||||
|
||||
const a = encounter.combatants.find((c) => c.id === combatantId("A"));
|
||||
expect(a?.initiative).toBe(8);
|
||||
expect(events[0]).toMatchObject({
|
||||
previousValue: 15,
|
||||
newValue: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it("AS-3: reject non-integer initiative value", () => {
|
||||
const e = enc([A, B], 0);
|
||||
const result = setInitiative(e, combatantId("A"), 3.5);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-initiative");
|
||||
}
|
||||
});
|
||||
|
||||
it("AS-3b: reject NaN", () => {
|
||||
const e = enc([A, B], 0);
|
||||
const result = setInitiative(e, combatantId("A"), Number.NaN);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("AS-3c: reject Infinity", () => {
|
||||
const e = enc([A, B], 0);
|
||||
const result = setInitiative(
|
||||
e,
|
||||
combatantId("A"),
|
||||
Number.POSITIVE_INFINITY,
|
||||
);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("AS-4: clear initiative moves combatant to end", () => {
|
||||
const e = enc([makeCombatant("A", 15), makeCombatant("B", 10)], 0);
|
||||
const { encounter } = successResult(e, "A", undefined);
|
||||
|
||||
const a = encounter.combatants.find((c) => c.id === combatantId("A"));
|
||||
expect(a?.initiative).toBeUndefined();
|
||||
// A should be after B now
|
||||
expect(names(encounter)).toEqual(["B", "A"]);
|
||||
});
|
||||
|
||||
it("returns error for nonexistent combatant", () => {
|
||||
const e = enc([A, B], 0);
|
||||
const result = setInitiative(e, combatantId("nonexistent"), 10);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- US2: Automatic Ordering ---
|
||||
|
||||
describe("US2: automatic ordering by initiative", () => {
|
||||
it("AS-1: orders combatants descending by initiative", () => {
|
||||
// Start with A(20), B(5), C(15) → should be A(20), C(15), B(5)
|
||||
const e = enc([
|
||||
makeCombatant("A", 20),
|
||||
makeCombatant("B", 5),
|
||||
makeCombatant("C", 15),
|
||||
]);
|
||||
// Set C's initiative to trigger reorder (no-op change to force sort)
|
||||
const { encounter } = successResult(e, "C", 15);
|
||||
expect(names(encounter)).toEqual(["A", "C", "B"]);
|
||||
});
|
||||
|
||||
it("AS-2: changing initiative reorders correctly", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", 20),
|
||||
makeCombatant("C", 15),
|
||||
makeCombatant("B", 5),
|
||||
]);
|
||||
const { encounter } = successResult(e, "B", 25);
|
||||
expect(names(encounter)).toEqual(["B", "A", "C"]);
|
||||
});
|
||||
|
||||
it("AS-3: stable sort for equal initiative values", () => {
|
||||
const e = enc([makeCombatant("A", 10), makeCombatant("B", 10)]);
|
||||
// Set A's initiative to same value to confirm stable sort
|
||||
const { encounter } = successResult(e, "A", 10);
|
||||
expect(names(encounter)).toEqual(["A", "B"]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- US3: Combatants Without Initiative ---
|
||||
|
||||
describe("US3: combatants without initiative", () => {
|
||||
it("AS-1: unset combatants appear after those with initiative", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", 15),
|
||||
B, // no initiative
|
||||
makeCombatant("C", 10),
|
||||
]);
|
||||
const { encounter } = successResult(e, "A", 15);
|
||||
expect(names(encounter)).toEqual(["A", "C", "B"]);
|
||||
});
|
||||
|
||||
it("AS-2: multiple unset combatants preserve relative order", () => {
|
||||
const e = enc([A, B]); // both no initiative
|
||||
const { encounter } = successResult(e, "A", undefined);
|
||||
expect(names(encounter)).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("AS-3: setting initiative moves combatant to correct position", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", 20),
|
||||
B, // no initiative
|
||||
makeCombatant("C", 10),
|
||||
]);
|
||||
const { encounter } = successResult(e, "B", 12);
|
||||
expect(names(encounter)).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
});
|
||||
|
||||
// --- US4: Active Turn Preservation ---
|
||||
|
||||
describe("US4: active turn preservation during reorder", () => {
|
||||
it("AS-1: reorder preserves active turn on different combatant", () => {
|
||||
// B is active (index 1), change A's initiative
|
||||
const e = enc(
|
||||
[makeCombatant("A", 10), makeCombatant("B", 15), makeCombatant("C", 5)],
|
||||
1,
|
||||
);
|
||||
// Change A's initiative to 20, causing reorder
|
||||
const { encounter } = successResult(e, "A", 20);
|
||||
// New order: A(20), B(15), C(5)
|
||||
expect(names(encounter)).toEqual(["A", "B", "C"]);
|
||||
// B should still be active
|
||||
expect(encounter.combatants[encounter.activeIndex].id).toBe(
|
||||
combatantId("B"),
|
||||
);
|
||||
});
|
||||
|
||||
it("AS-2: active combatant's own initiative change preserves turn", () => {
|
||||
const e = enc(
|
||||
[makeCombatant("A", 20), makeCombatant("B", 15), makeCombatant("C", 5)],
|
||||
0, // A is active
|
||||
);
|
||||
// Change A's initiative to 1, causing it to move to the end
|
||||
const { encounter } = successResult(e, "A", 1);
|
||||
// New order: B(15), C(5), A(1)
|
||||
expect(names(encounter)).toEqual(["B", "C", "A"]);
|
||||
// A should still be active
|
||||
expect(encounter.combatants[encounter.activeIndex].id).toBe(
|
||||
combatantId("A"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Invariants ---
|
||||
|
||||
describe("invariants", () => {
|
||||
it("determinism — same input produces same output", () => {
|
||||
const e = enc([A, B, C], 1, 3);
|
||||
const result1 = setInitiative(e, combatantId("A"), 10);
|
||||
const result2 = setInitiative(e, combatantId("A"), 10);
|
||||
expect(result1).toEqual(result2);
|
||||
});
|
||||
|
||||
it("immutability — input encounter is not mutated", () => {
|
||||
const e = enc([A, B], 0, 2);
|
||||
const original = JSON.parse(JSON.stringify(e));
|
||||
setInitiative(e, combatantId("A"), 10);
|
||||
expect(e).toEqual(original);
|
||||
});
|
||||
|
||||
it("event shape includes all required fields", () => {
|
||||
const e = enc([makeCombatant("A", 5), B], 0);
|
||||
const { events } = successResult(e, "A", 10);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toEqual({
|
||||
type: "InitiativeSet",
|
||||
combatantId: combatantId("A"),
|
||||
previousValue: 5,
|
||||
newValue: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("roundNumber is never changed", () => {
|
||||
const e = enc([A, B], 0, 7);
|
||||
const { encounter } = successResult(e, "A", 10);
|
||||
expect(encounter.roundNumber).toBe(7);
|
||||
});
|
||||
|
||||
it("every success emits exactly one InitiativeSet event", () => {
|
||||
const scenarios: [Encounter, string, number | undefined][] = [
|
||||
[enc([A]), "A", 10],
|
||||
[enc([A, B], 1), "A", 5],
|
||||
[enc([makeCombatant("A", 10)]), "A", undefined],
|
||||
];
|
||||
|
||||
for (const [e, id, value] of scenarios) {
|
||||
const { events } = successResult(e, id, value);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].type).toBe("InitiativeSet");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Edge Cases ---
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("zero is a valid initiative value", () => {
|
||||
const e = enc([A, B], 0);
|
||||
const { encounter } = successResult(e, "A", 0);
|
||||
const a = encounter.combatants.find((c) => c.id === combatantId("A"));
|
||||
expect(a?.initiative).toBe(0);
|
||||
});
|
||||
|
||||
it("negative initiative is valid", () => {
|
||||
const e = enc([A, B], 0);
|
||||
const { encounter } = successResult(e, "A", -5);
|
||||
const a = encounter.combatants.find((c) => c.id === combatantId("A"));
|
||||
expect(a?.initiative).toBe(-5);
|
||||
});
|
||||
|
||||
it("negative sorts below positive", () => {
|
||||
const e = enc([makeCombatant("A", -3), makeCombatant("B", 10)]);
|
||||
const { encounter } = successResult(e, "A", -3);
|
||||
expect(names(encounter)).toEqual(["B", "A"]);
|
||||
});
|
||||
|
||||
it("all combatants with same initiative preserve order", () => {
|
||||
const e = enc([
|
||||
makeCombatant("A", 10),
|
||||
makeCombatant("B", 10),
|
||||
makeCombatant("C", 10),
|
||||
]);
|
||||
const { encounter } = successResult(e, "B", 10);
|
||||
expect(names(encounter)).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
|
||||
it("clearing initiative on last combatant with initiative", () => {
|
||||
const e = enc([makeCombatant("A", 10), B], 0);
|
||||
const { encounter } = successResult(e, "A", undefined);
|
||||
// Both unset now, preserve relative order
|
||||
expect(names(encounter)).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
it("undefined value skips integer validation", () => {
|
||||
const e = enc([A], 0);
|
||||
const result = setInitiative(e, combatantId("A"), undefined);
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user