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

View File

@@ -32,9 +32,17 @@ export interface CombatantUpdated {
readonly newName: string;
}
export interface InitiativeSet {
readonly type: "InitiativeSet";
readonly combatantId: CombatantId;
readonly previousValue: number | undefined;
readonly newValue: number | undefined;
}
export type DomainEvent =
| TurnAdvanced
| RoundAdvanced
| CombatantAdded
| CombatantRemoved
| CombatantUpdated;
| CombatantUpdated
| InitiativeSet;

View File

@@ -9,6 +9,7 @@ export type {
CombatantRemoved,
CombatantUpdated,
DomainEvent,
InitiativeSet,
RoundAdvanced,
TurnAdvanced,
} from "./events.js";
@@ -16,6 +17,10 @@ export {
type RemoveCombatantSuccess,
removeCombatant,
} from "./remove-combatant.js";
export {
type SetInitiativeSuccess,
setInitiative,
} from "./set-initiative.js";
export {
type Combatant,
type CombatantId,

View File

@@ -0,0 +1,101 @@
import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js";
export interface SetInitiativeSuccess {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
/**
* Pure function that sets, changes, or clears a combatant's initiative value.
*
* After updating the value, combatants are stable-sorted:
* 1. Combatants with initiative — descending by value
* 2. Combatants without initiative — preserve relative order
*
* The active combatant's turn is preserved through the reorder
* by tracking identity (CombatantId) rather than position.
*
* roundNumber is never changed.
*/
export function setInitiative(
encounter: Encounter,
combatantId: CombatantId,
value: number | undefined,
): SetInitiativeSuccess | DomainError {
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
if (value !== undefined && !Number.isInteger(value)) {
return {
kind: "domain-error",
code: "invalid-initiative",
message: `Initiative must be an integer, got ${value}`,
};
}
const target = encounter.combatants[targetIdx];
const previousValue = target.initiative;
// Record active combatant's id before reorder
const activeCombatantId =
encounter.combatants.length > 0
? encounter.combatants[encounter.activeIndex].id
: undefined;
// Create new combatants array with updated initiative
const updated = encounter.combatants.map((c) =>
c.id === combatantId ? { ...c, initiative: value } : c,
);
// Stable sort: initiative descending, undefined last
const indexed = updated.map((c, i) => ({ c, i }));
indexed.sort((a, b) => {
const aHas = a.c.initiative !== undefined;
const bHas = b.c.initiative !== undefined;
if (aHas && bHas) {
// biome-ignore lint: both checked above
const diff = b.c.initiative! - a.c.initiative!;
return diff !== 0 ? diff : a.i - b.i;
}
if (aHas && !bHas) return -1;
if (!aHas && bHas) return 1;
// Both undefined — preserve relative order
return a.i - b.i;
});
const sorted = indexed.map(({ c }) => c);
// Find active combatant's new index
let newActiveIndex = encounter.activeIndex;
if (activeCombatantId !== undefined) {
const idx = sorted.findIndex((c) => c.id === activeCombatantId);
if (idx !== -1) {
newActiveIndex = idx;
}
}
return {
encounter: {
combatants: sorted,
activeIndex: newActiveIndex,
roundNumber: encounter.roundNumber,
},
events: [
{
type: "InitiativeSet",
combatantId,
previousValue,
newValue: value,
},
],
};
}

View File

@@ -8,6 +8,7 @@ export function combatantId(id: string): CombatantId {
export interface Combatant {
readonly id: CombatantId;
readonly name: string;
readonly initiative?: number;
}
export interface Encounter {