Implement the 003-remove-combatant feature that adds the possibility to remove a combatant from an encounter
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
export { addCombatantUseCase } from "./add-combatant-use-case.js";
|
||||
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
||||
export type { EncounterStore } from "./ports.js";
|
||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||
|
||||
23
packages/application/src/remove-combatant-use-case.ts
Normal file
23
packages/application/src/remove-combatant-use-case.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
removeCombatant,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
|
||||
export function removeCombatantUseCase(
|
||||
store: EncounterStore,
|
||||
id: CombatantId,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = removeCombatant(encounter, id);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
}
|
||||
142
packages/domain/src/__tests__/remove-combatant.test.ts
Normal file
142
packages/domain/src/__tests__/remove-combatant.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { removeCombatant } from "../remove-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");
|
||||
const D = makeCombatant("D");
|
||||
|
||||
function enc(
|
||||
combatants: Combatant[],
|
||||
activeIndex = 0,
|
||||
roundNumber = 1,
|
||||
): Encounter {
|
||||
return { combatants, activeIndex, roundNumber };
|
||||
}
|
||||
|
||||
function successResult(encounter: Encounter, id: string) {
|
||||
const result = removeCombatant(encounter, combatantId(id));
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Acceptance Scenarios ---
|
||||
|
||||
describe("removeCombatant", () => {
|
||||
describe("acceptance scenarios", () => {
|
||||
it("AS-1: remove combatant after active — activeIndex unchanged", () => {
|
||||
// [A*, B, C] remove C → [A*, B], activeIndex stays 0
|
||||
const e = enc([A, B, C], 0, 2);
|
||||
const { encounter, events } = successResult(e, "C");
|
||||
|
||||
expect(encounter.combatants).toEqual([A, B]);
|
||||
expect(encounter.activeIndex).toBe(0);
|
||||
expect(encounter.roundNumber).toBe(2);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "CombatantRemoved",
|
||||
combatantId: combatantId("C"),
|
||||
name: "C",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("AS-2: remove combatant before active — activeIndex decrements", () => {
|
||||
// [A, B, C*] remove A → [B, C*], activeIndex 2→1
|
||||
const e = enc([A, B, C], 2, 3);
|
||||
const { encounter } = successResult(e, "A");
|
||||
|
||||
expect(encounter.combatants).toEqual([B, C]);
|
||||
expect(encounter.activeIndex).toBe(1);
|
||||
expect(encounter.roundNumber).toBe(3);
|
||||
});
|
||||
|
||||
it("AS-3: remove active combatant mid-list — next slides in", () => {
|
||||
// [A, B*, C, D] remove B → [A, C*, D], activeIndex stays 1
|
||||
const e = enc([A, B, C, D], 1, 1);
|
||||
const { encounter } = successResult(e, "B");
|
||||
|
||||
expect(encounter.combatants).toEqual([A, C, D]);
|
||||
expect(encounter.activeIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("AS-4: remove active combatant at end — wraps to 0", () => {
|
||||
// [A, B, C*] remove C → [A, B], activeIndex wraps to 0
|
||||
const e = enc([A, B, C], 2, 1);
|
||||
const { encounter } = successResult(e, "C");
|
||||
|
||||
expect(encounter.combatants).toEqual([A, B]);
|
||||
expect(encounter.activeIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("AS-5: remove only combatant — empty list, activeIndex 0", () => {
|
||||
const e = enc([A], 0, 5);
|
||||
const { encounter } = successResult(e, "A");
|
||||
|
||||
expect(encounter.combatants).toEqual([]);
|
||||
expect(encounter.activeIndex).toBe(0);
|
||||
expect(encounter.roundNumber).toBe(5);
|
||||
});
|
||||
|
||||
it("AS-6: ID not found — returns DomainError", () => {
|
||||
const e = enc([A, B], 0, 1);
|
||||
const result = removeCombatant(e, combatantId("nonexistent"));
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("invariants", () => {
|
||||
it("event shape includes combatantId and name", () => {
|
||||
const e = enc([A, B], 0, 1);
|
||||
const { events } = successResult(e, "B");
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toEqual({
|
||||
type: "CombatantRemoved",
|
||||
combatantId: combatantId("B"),
|
||||
name: "B",
|
||||
});
|
||||
});
|
||||
|
||||
it("roundNumber never changes on removal", () => {
|
||||
const e = enc([A, B, C], 1, 7);
|
||||
const { encounter } = successResult(e, "A");
|
||||
expect(encounter.roundNumber).toBe(7);
|
||||
});
|
||||
|
||||
it("determinism — same input produces same output", () => {
|
||||
const e = enc([A, B, C], 1, 3);
|
||||
const result1 = removeCombatant(e, combatantId("B"));
|
||||
const result2 = removeCombatant(e, combatantId("B"));
|
||||
expect(result1).toEqual(result2);
|
||||
});
|
||||
|
||||
it("every success emits exactly one CombatantRemoved event", () => {
|
||||
const scenarios: [Encounter, string][] = [
|
||||
[enc([A]), "A"],
|
||||
[enc([A, B], 1), "A"],
|
||||
[enc([A, B, C], 2, 5), "C"],
|
||||
];
|
||||
|
||||
for (const [e, id] of scenarios) {
|
||||
const { events } = successResult(e, id);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].type).toBe("CombatantRemoved");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,4 +19,14 @@ export interface CombatantAdded {
|
||||
readonly position: number;
|
||||
}
|
||||
|
||||
export type DomainEvent = TurnAdvanced | RoundAdvanced | CombatantAdded;
|
||||
export interface CombatantRemoved {
|
||||
readonly type: "CombatantRemoved";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export type DomainEvent =
|
||||
| TurnAdvanced
|
||||
| RoundAdvanced
|
||||
| CombatantAdded
|
||||
| CombatantRemoved;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js";
|
||||
export { advanceTurn } from "./advance-turn.js";
|
||||
|
||||
export type {
|
||||
CombatantAdded,
|
||||
CombatantRemoved,
|
||||
DomainEvent,
|
||||
RoundAdvanced,
|
||||
TurnAdvanced,
|
||||
} from "./events.js";
|
||||
export {
|
||||
type RemoveCombatantSuccess,
|
||||
removeCombatant,
|
||||
} from "./remove-combatant.js";
|
||||
export {
|
||||
type Combatant,
|
||||
type CombatantId,
|
||||
|
||||
65
packages/domain/src/remove-combatant.ts
Normal file
65
packages/domain/src/remove-combatant.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||
|
||||
export interface RemoveCombatantSuccess {
|
||||
readonly encounter: Encounter;
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that removes a combatant from an encounter by ID.
|
||||
*
|
||||
* Adjusts activeIndex to preserve turn integrity:
|
||||
* - Removed after active → unchanged
|
||||
* - Removed before active → decrement
|
||||
* - Removed is active, mid-list → same index (next slides in)
|
||||
* - Removed is active, at end → wrap to 0
|
||||
* - Only combatant removed → 0
|
||||
*
|
||||
* roundNumber is never changed.
|
||||
*/
|
||||
export function removeCombatant(
|
||||
encounter: Encounter,
|
||||
id: CombatantId,
|
||||
): RemoveCombatantSuccess | DomainError {
|
||||
const removedIdx = encounter.combatants.findIndex((c) => c.id === id);
|
||||
|
||||
if (removedIdx === -1) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "combatant-not-found",
|
||||
message: `No combatant found with ID "${id}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const removed = encounter.combatants[removedIdx];
|
||||
const newCombatants = encounter.combatants.filter((_, i) => i !== removedIdx);
|
||||
|
||||
let newActiveIndex: number;
|
||||
if (newCombatants.length === 0) {
|
||||
newActiveIndex = 0;
|
||||
} else if (removedIdx < encounter.activeIndex) {
|
||||
newActiveIndex = encounter.activeIndex - 1;
|
||||
} else if (removedIdx > encounter.activeIndex) {
|
||||
newActiveIndex = encounter.activeIndex;
|
||||
} else {
|
||||
// removedIdx === activeIndex
|
||||
newActiveIndex =
|
||||
removedIdx >= newCombatants.length ? 0 : encounter.activeIndex;
|
||||
}
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
combatants: newCombatants,
|
||||
activeIndex: newActiveIndex,
|
||||
roundNumber: encounter.roundNumber,
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: "CombatantRemoved",
|
||||
combatantId: removed.id,
|
||||
name: removed.name,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user