Feature: implement return to town and resume dungeon flow with state management

This commit is contained in:
Keith Solomon
2026-03-18 18:42:43 -05:00
parent 626d5ca05c
commit a4d2890cd9
6 changed files with 192 additions and 36 deletions
+11 -32
View File
@@ -10,6 +10,8 @@ import {
isCurrentRoomCombatReady,
resolveRunEnemyTurn,
resolveRunPlayerTurn,
resumeDungeon,
returnToTown,
startCombatInCurrentRoom,
travelCurrentExit,
} from "./runState";
@@ -44,6 +46,7 @@ describe("run state flow", () => {
expect(run.currentLevel).toBe(1);
expect(run.currentRoomId).toBe("room.level1.start");
expect(run.phase).toBe("dungeon");
expect(run.dungeon.levels["1"]?.rooms["room.level1.start"]).toBeDefined();
});
@@ -254,22 +257,7 @@ describe("run state flow", () => {
expect(isCurrentRoomCombatReady(run)).toBe(true);
});
it("lists available traversable exits for the current room", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
expect(getAvailableMoves(run)).toEqual([
expect.objectContaining({
direction: "north",
generated: false,
}),
]);
});
it("travels through an unresolved exit, generates a room, and enters it", () => {
it("returns to town and later resumes the dungeon", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
@@ -277,22 +265,13 @@ describe("run state flow", () => {
at: "2026-03-15T14:00:00.000Z",
});
const result = travelCurrentExit({
content: sampleContentPack,
run,
exitDirection: "north",
roller: createSequenceRoller([1, 1]),
at: "2026-03-15T14:05:00.000Z",
});
const inTown = returnToTown(run, "2026-03-15T15:00:00.000Z").run;
const resumed = resumeDungeon(inTown, "2026-03-15T15:10:00.000Z").run;
expect(result.run.currentRoomId).toBe("room.level1.room.002");
expect(result.run.dungeon.levels["1"]!.discoveredRoomOrder).toEqual([
"room.level1.start",
"room.level1.room.002",
]);
expect(result.run.dungeon.levels["1"]!.rooms["room.level1.room.002"]!.discovery.entered).toBe(
true,
);
expect(result.run.log[0]?.text).toContain("Travelled north");
expect(inTown.phase).toBe("town");
expect(inTown.lastTownAt).toBe("2026-03-15T15:00:00.000Z");
expect(getAvailableMoves(inTown)).toEqual([]);
expect(resumed.phase).toBe("dungeon");
expect(resumed.log.at(-1)?.text).toContain("resumed the dungeon delve");
});
});
+100
View File
@@ -82,6 +82,22 @@ export type RunTransitionResult = {
logEntries: LogEntry[];
};
function createLogEntry(
id: string,
at: string,
type: LogEntry["type"],
text: string,
relatedIds?: string[],
): LogEntry {
return {
id,
at,
type,
text,
relatedIds,
};
}
function cloneCombat(combat: CombatState): CombatState {
return {
...combat,
@@ -290,6 +306,7 @@ export function createRunState(options: CreateRunOptions): RunState {
id: options.runId ?? "run.active",
campaignId: options.campaignId,
status: "active",
phase: "dungeon",
startedAt: at,
currentLevel: 1,
currentRoomId: "room.level1.start",
@@ -310,10 +327,68 @@ export function createRunState(options: CreateRunOptions): RunState {
};
}
export function returnToTown(
run: RunState,
at = new Date().toISOString(),
): RunTransitionResult {
const nextRun = cloneRun(run);
if (nextRun.activeCombat) {
throw new Error("Cannot return to town during active combat.");
}
nextRun.phase = "town";
nextRun.lastTownAt = at;
const logEntry = createLogEntry(
`run.return-to-town.${nextRun.log.length + 1}`,
at,
"town",
`Returned to town from level ${nextRun.currentLevel}.`,
nextRun.currentRoomId ? [nextRun.currentRoomId] : undefined,
);
appendLogs(nextRun, [logEntry]);
return {
run: nextRun,
logEntries: [logEntry],
};
}
export function resumeDungeon(
run: RunState,
at = new Date().toISOString(),
): RunTransitionResult {
const nextRun = cloneRun(run);
nextRun.phase = "dungeon";
const logEntry = createLogEntry(
`run.resume-dungeon.${nextRun.log.length + 1}`,
at,
"room",
`Left town and resumed the dungeon delve on level ${nextRun.currentLevel}.`,
nextRun.currentRoomId ? [nextRun.currentRoomId] : undefined,
);
appendLogs(nextRun, [logEntry]);
return {
run: nextRun,
logEntries: [logEntry],
};
}
export function enterCurrentRoom(
options: EnterCurrentRoomOptions,
): RunTransitionResult {
const run = cloneRun(options.run);
if (run.phase !== "dungeon") {
throw new Error("Cannot enter rooms while the run is in town.");
}
const levelState = requireCurrentLevel(run);
const roomId = requireCurrentRoomId(run);
const entry = enterRoom({
@@ -334,6 +409,10 @@ export function enterCurrentRoom(
}
export function getAvailableMoves(run: RunState): AvailableMove[] {
if (run.phase !== "dungeon") {
return [];
}
const room = requireCurrentRoom(run);
return room.exits
@@ -348,6 +427,10 @@ export function getAvailableMoves(run: RunState): AvailableMove[] {
}
export function isCurrentRoomCombatReady(run: RunState) {
if (run.phase !== "dungeon") {
return false;
}
const room = requireCurrentRoom(run);
return Boolean(
@@ -362,6 +445,10 @@ export function travelCurrentExit(
): RunTransitionResult {
const run = cloneRun(options.run);
if (run.phase !== "dungeon") {
throw new Error("Cannot travel while the run is in town.");
}
if (run.activeCombat) {
throw new Error("Cannot travel while combat is active.");
}
@@ -437,6 +524,11 @@ export function startCombatInCurrentRoom(
options: StartCurrentCombatOptions,
): RunTransitionResult {
const run = cloneRun(options.run);
if (run.phase !== "dungeon") {
throw new Error("Cannot start combat while the run is in town.");
}
const levelState = requireCurrentLevel(run);
const roomId = requireCurrentRoomId(run);
const room = levelState.rooms[roomId];
@@ -467,6 +559,10 @@ export function resolveRunPlayerTurn(
): RunTransitionResult {
const run = cloneRun(options.run);
if (run.phase !== "dungeon") {
throw new Error("Cannot resolve combat while the run is in town.");
}
if (!run.activeCombat) {
throw new Error("Run does not have an active combat.");
}
@@ -517,6 +613,10 @@ export function resolveRunEnemyTurn(
): RunTransitionResult {
const run = cloneRun(options.run);
if (run.phase !== "dungeon") {
throw new Error("Cannot resolve combat while the run is in town.");
}
if (!run.activeCombat) {
throw new Error("Run does not have an active combat.");
}