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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
101
packages/domain/src/set-initiative.ts
Normal file
101
packages/domain/src/set-initiative.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user