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:
Lukas
2026-03-04 17:26:41 +01:00
parent a9df826fef
commit fea2bfe39d
17 changed files with 1107 additions and 1 deletions

View 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);
});
});
});