T007–T011: implement AdvanceTurn domain logic (pure function, events, invariants, 8 acceptance tests)

This commit is contained in:
Lukas
2026-03-03 13:01:08 +01:00
parent 7dd4abb12a
commit 42a07a07ff
6 changed files with 402 additions and 6 deletions

View File

@@ -0,0 +1,65 @@
import type { DomainEvent } from "./events.js";
import type { DomainError, Encounter } from "./types.js";
import { isDomainError } from "./types.js";
interface AdvanceTurnSuccess {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
/**
* Pure function that advances the turn to the next combatant.
*
* FR-001: Accepts an Encounter and returns next state + events.
* FR-002: Increments activeIndex by 1, wrapping to 0.
* FR-003: When wrapping, increments roundNumber by 1.
* FR-004: Empty encounter returns error (no state change, no events).
* FR-005: Events returned as values, not dispatched via side effects.
*/
export function advanceTurn(
encounter: Encounter,
): AdvanceTurnSuccess | DomainError {
// FR-004 / INV-1: reject empty encounters
if (encounter.combatants.length === 0) {
return {
kind: "domain-error",
code: "invalid-encounter",
message: "Cannot advance turn on an encounter with no combatants",
};
}
const previousIndex = encounter.activeIndex;
const nextIndex = (previousIndex + 1) % encounter.combatants.length;
const wraps = nextIndex === 0;
const newRoundNumber = wraps
? encounter.roundNumber + 1
: encounter.roundNumber;
const events: DomainEvent[] = [
{
type: "TurnAdvanced",
previousCombatantId: encounter.combatants[previousIndex].id,
newCombatantId: encounter.combatants[nextIndex].id,
roundNumber: newRoundNumber,
},
];
// Event ordering contract: TurnAdvanced first, then RoundAdvanced
if (wraps) {
events.push({
type: "RoundAdvanced",
newRoundNumber,
});
}
return {
encounter: {
combatants: encounter.combatants,
activeIndex: nextIndex,
roundNumber: newRoundNumber,
},
events,
};
}
export { isDomainError };