✨Feature: implement return to town and resume dungeon flow with state management
This commit is contained in:
+11
-32
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user