Implement the 012-turn-navigation feature that adds a RetreatTurn domain operation and relocates turn controls to a navigation bar at the top of the encounter tracker
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,5 +4,6 @@ export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
||||
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
||||
export type { EncounterStore } from "./ports.js";
|
||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||
|
||||
21
packages/application/src/retreat-turn-use-case.ts
Normal file
21
packages/application/src/retreat-turn-use-case.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
retreatTurn,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
|
||||
export function retreatTurnUseCase(
|
||||
store: EncounterStore,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = retreatTurn(encounter);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
}
|
||||
184
packages/domain/src/__tests__/retreat-turn.test.ts
Normal file
184
packages/domain/src/__tests__/retreat-turn.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DomainEvent } from "../events.js";
|
||||
import { retreatTurn } from "../retreat-turn.js";
|
||||
import {
|
||||
type Combatant,
|
||||
combatantId,
|
||||
createEncounter,
|
||||
type Encounter,
|
||||
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");
|
||||
|
||||
function encounter(
|
||||
combatants: Combatant[],
|
||||
activeIndex: number,
|
||||
roundNumber: number,
|
||||
): Encounter {
|
||||
const result = createEncounter(combatants, activeIndex, roundNumber);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Test setup failed: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function successResult(enc: Encounter) {
|
||||
const result = retreatTurn(enc);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Acceptance Scenarios ---
|
||||
|
||||
describe("retreatTurn", () => {
|
||||
describe("acceptance scenarios", () => {
|
||||
it("scenario 1: mid-round retreat — retreats from second to first combatant", () => {
|
||||
const enc = encounter([A, B, C], 1, 1);
|
||||
const { encounter: next, events } = successResult(enc);
|
||||
|
||||
expect(next.activeIndex).toBe(0);
|
||||
expect(next.roundNumber).toBe(1);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "TurnRetreated",
|
||||
previousCombatantId: combatantId("B"),
|
||||
newCombatantId: combatantId("A"),
|
||||
roundNumber: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("scenario 2: round-boundary retreat — wraps from first combatant to last, decrements round", () => {
|
||||
const enc = encounter([A, B, C], 0, 2);
|
||||
const { encounter: next, events } = successResult(enc);
|
||||
|
||||
expect(next.activeIndex).toBe(2);
|
||||
expect(next.roundNumber).toBe(1);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "TurnRetreated",
|
||||
previousCombatantId: combatantId("A"),
|
||||
newCombatantId: combatantId("C"),
|
||||
roundNumber: 1,
|
||||
},
|
||||
{
|
||||
type: "RoundRetreated",
|
||||
newRoundNumber: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("scenario 3: start-of-encounter error — cannot retreat at round 1 index 0", () => {
|
||||
const enc = encounter([A, B, C], 0, 1);
|
||||
const result = retreatTurn(enc);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("no-previous-turn");
|
||||
}
|
||||
});
|
||||
|
||||
it("scenario 4: single-combatant retreat — wraps to same combatant, decrements round", () => {
|
||||
const enc = encounter([A], 0, 2);
|
||||
const { encounter: next, events } = successResult(enc);
|
||||
|
||||
expect(next.activeIndex).toBe(0);
|
||||
expect(next.roundNumber).toBe(1);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "TurnRetreated",
|
||||
previousCombatantId: combatantId("A"),
|
||||
newCombatantId: combatantId("A"),
|
||||
roundNumber: 1,
|
||||
},
|
||||
{
|
||||
type: "RoundRetreated",
|
||||
newRoundNumber: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("scenario 5: empty-encounter error", () => {
|
||||
const enc: Encounter = {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const result = retreatTurn(enc);
|
||||
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
if (isDomainError(result)) {
|
||||
expect(result.code).toBe("invalid-encounter");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("invariants", () => {
|
||||
it("determinism — same input produces same output", () => {
|
||||
const enc = encounter([A, B, C], 1, 3);
|
||||
const result1 = retreatTurn(enc);
|
||||
const result2 = retreatTurn(enc);
|
||||
expect(result1).toEqual(result2);
|
||||
});
|
||||
|
||||
it("activeIndex always in bounds after retreat", () => {
|
||||
const combatants = [A, B, C];
|
||||
// Start at round 4 so we can retreat many times
|
||||
let enc = encounter(combatants, 2, 4);
|
||||
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const result = successResult(enc);
|
||||
expect(result.encounter.activeIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(result.encounter.activeIndex).toBeLessThan(combatants.length);
|
||||
enc = result.encounter;
|
||||
}
|
||||
});
|
||||
|
||||
it("roundNumber never goes below 1", () => {
|
||||
let enc = encounter([A, B, C], 2, 2);
|
||||
|
||||
// Retreat through rounds — should stop at round 1 index 0
|
||||
while (!(enc.roundNumber === 1 && enc.activeIndex === 0)) {
|
||||
const result = successResult(enc);
|
||||
expect(result.encounter.roundNumber).toBeGreaterThanOrEqual(1);
|
||||
enc = result.encounter;
|
||||
}
|
||||
});
|
||||
|
||||
it("every success emits at least TurnRetreated", () => {
|
||||
const scenarios: Encounter[] = [
|
||||
encounter([A, B, C], 1, 1),
|
||||
encounter([A, B, C], 0, 2),
|
||||
encounter([A], 0, 2),
|
||||
];
|
||||
|
||||
for (const enc of scenarios) {
|
||||
const result = successResult(enc);
|
||||
const hasTurnRetreated = result.events.some(
|
||||
(e: DomainEvent) => e.type === "TurnRetreated",
|
||||
);
|
||||
expect(hasTurnRetreated).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("event ordering: on wrap, events are [TurnRetreated, RoundRetreated]", () => {
|
||||
const enc = encounter([A, B, C], 0, 2);
|
||||
const { events } = successResult(enc);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].type).toBe("TurnRetreated");
|
||||
expect(events[1].type).toBe("RoundRetreated");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,18 @@ export interface CurrentHpAdjusted {
|
||||
readonly delta: number;
|
||||
}
|
||||
|
||||
export interface TurnRetreated {
|
||||
readonly type: "TurnRetreated";
|
||||
readonly previousCombatantId: CombatantId;
|
||||
readonly newCombatantId: CombatantId;
|
||||
readonly roundNumber: number;
|
||||
}
|
||||
|
||||
export interface RoundRetreated {
|
||||
readonly type: "RoundRetreated";
|
||||
readonly newRoundNumber: number;
|
||||
}
|
||||
|
||||
export type DomainEvent =
|
||||
| TurnAdvanced
|
||||
| RoundAdvanced
|
||||
@@ -64,4 +76,6 @@ export type DomainEvent =
|
||||
| CombatantUpdated
|
||||
| InitiativeSet
|
||||
| MaxHpSet
|
||||
| CurrentHpAdjusted;
|
||||
| CurrentHpAdjusted
|
||||
| TurnRetreated
|
||||
| RoundRetreated;
|
||||
|
||||
@@ -14,12 +14,15 @@ export type {
|
||||
InitiativeSet,
|
||||
MaxHpSet,
|
||||
RoundAdvanced,
|
||||
RoundRetreated,
|
||||
TurnAdvanced,
|
||||
TurnRetreated,
|
||||
} from "./events.js";
|
||||
export {
|
||||
type RemoveCombatantSuccess,
|
||||
removeCombatant,
|
||||
} from "./remove-combatant.js";
|
||||
export { retreatTurn } from "./retreat-turn.js";
|
||||
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
||||
export {
|
||||
type SetInitiativeSuccess,
|
||||
|
||||
59
packages/domain/src/retreat-turn.ts
Normal file
59
packages/domain/src/retreat-turn.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import type { DomainError, Encounter } from "./types.js";
|
||||
|
||||
interface RetreatTurnSuccess {
|
||||
readonly encounter: Encounter;
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
export function retreatTurn(
|
||||
encounter: Encounter,
|
||||
): RetreatTurnSuccess | DomainError {
|
||||
if (encounter.combatants.length === 0) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "invalid-encounter",
|
||||
message: "Cannot retreat turn on an encounter with no combatants",
|
||||
};
|
||||
}
|
||||
|
||||
if (encounter.roundNumber === 1 && encounter.activeIndex === 0) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "no-previous-turn",
|
||||
message: "Cannot retreat before the start of the encounter",
|
||||
};
|
||||
}
|
||||
|
||||
const previousIndex = encounter.activeIndex;
|
||||
const wraps = previousIndex === 0;
|
||||
const newIndex = wraps ? encounter.combatants.length - 1 : previousIndex - 1;
|
||||
const newRoundNumber = wraps
|
||||
? encounter.roundNumber - 1
|
||||
: encounter.roundNumber;
|
||||
|
||||
const events: DomainEvent[] = [
|
||||
{
|
||||
type: "TurnRetreated",
|
||||
previousCombatantId: encounter.combatants[previousIndex].id,
|
||||
newCombatantId: encounter.combatants[newIndex].id,
|
||||
roundNumber: newRoundNumber,
|
||||
},
|
||||
];
|
||||
|
||||
if (wraps) {
|
||||
events.push({
|
||||
type: "RoundRetreated",
|
||||
newRoundNumber,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
combatants: encounter.combatants,
|
||||
activeIndex: newIndex,
|
||||
roundNumber: newRoundNumber,
|
||||
},
|
||||
events,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user