1 Commits

Author SHA1 Message Date
Keith Solomon
a4d2890cd9 Feature: implement return to town and resume dungeon flow with state management 2026-03-18 18:42:43 -05:00
6 changed files with 192 additions and 36 deletions

View File

@@ -9,6 +9,8 @@ import {
isCurrentRoomCombatReady,
resolveRunEnemyTurn,
resolveRunPlayerTurn,
resumeDungeon,
returnToTown,
startCombatInCurrentRoom,
travelCurrentExit,
} from "@/rules/runState";
@@ -54,6 +56,7 @@ function App() {
const currentRoom = run.currentRoomId ? currentLevel?.rooms[run.currentRoomId] : undefined;
const availableMoves = getAvailableMoves(run);
const combatReadyEncounter = isCurrentRoomCombatReady(run);
const inTown = run.phase === "town";
const handleReset = () => {
setRun(createDemoRun());
@@ -96,6 +99,14 @@ function App() {
);
};
const handleReturnToTown = () => {
setRun((previous) => returnToTown(previous).run);
};
const handleResumeDungeon = () => {
setRun((previous) => resumeDungeon(previous).run);
};
return (
<main className="app-shell">
<section className="hero">
@@ -112,14 +123,21 @@ function App() {
<button className="button button-primary" onClick={handleReset}>
Reset Demo Run
</button>
<button
className="button"
onClick={inTown ? handleResumeDungeon : handleReturnToTown}
disabled={Boolean(run.activeCombat)}
>
{inTown ? "Resume Dungeon" : "Return To Town"}
</button>
<div className="status-chip">
<span>Run Status</span>
<strong>{run.status}</strong>
<span>Run Phase</span>
<strong>{run.phase}</strong>
</div>
</div>
</section>
{combatReadyEncounter && !run.activeCombat ? (
{combatReadyEncounter && !run.activeCombat && !inTown ? (
<section className="alert-banner">
<div>
<span className="alert-kicker">Encounter Ready</span>
@@ -175,9 +193,47 @@ function App() {
<p className="supporting-text">
Run rewards: {run.xpGained} XP earned, {run.defeatedCreatureIds.length} foes defeated.
</p>
<p className="supporting-text">
{inTown
? `The party is currently in town${run.lastTownAt ? ` as of ${new Date(run.lastTownAt).toLocaleString()}` : ""}.`
: "The party is still delving below ground."}
</p>
</article>
<article className="panel">
{inTown ? (
<article className="panel panel-town-hub">
<div className="panel-header">
<h2>Town Hub</h2>
<span>Between Delves</span>
</div>
<h3 className="room-title">Safe Harbor</h3>
<p className="supporting-text">
You are out of the dungeon. Review the current expedition, catch your breath, and
then resume the delve from the same level when you are ready.
</p>
<div className="town-summary-grid">
<div className="encounter-box">
<span className="encounter-label">Current Gold</span>
<strong>{run.adventurerSnapshot.inventory.currency.gold}</strong>
</div>
<div className="encounter-box">
<span className="encounter-label">Rooms Found</span>
<strong>{currentLevel?.discoveredRoomOrder.length ?? 0}</strong>
</div>
<div className="encounter-box">
<span className="encounter-label">Foes Defeated</span>
<strong>{run.defeatedCreatureIds.length}</strong>
</div>
</div>
<div className="button-row">
<button className="button button-primary" onClick={handleResumeDungeon}>
Resume Delve
</button>
</div>
</article>
) : (
<>
<article className="panel">
<div className="panel-header">
<h2>Current Room</h2>
<span>Level {run.currentLevel}</span>
@@ -318,6 +374,8 @@ function App() {
</p>
)}
</article>
</>
)}
<article className="panel panel-log">
<div className="panel-header">

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");
});
});

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.");
}

View File

@@ -208,7 +208,9 @@ export const runStateSchema = z.object({
id: z.string().min(1),
campaignId: z.string().min(1),
status: z.enum(["active", "paused", "completed", "failed"]),
phase: z.enum(["dungeon", "town"]),
startedAt: z.string().min(1),
lastTownAt: z.string().optional(),
currentLevel: z.number().int().positive(),
currentRoomId: z.string().optional(),
dungeon: dungeonStateSchema,

View File

@@ -156,6 +156,10 @@ select {
grid-column: span 12;
}
.panel-town-hub {
grid-column: span 8;
}
.panel-header {
display: flex;
justify-content: space-between;
@@ -217,6 +221,13 @@ select {
color: rgba(244, 239, 227, 0.76);
}
.town-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 1rem;
}
.room-title {
margin: 0 0 0.35rem;
font-size: 1.5rem;
@@ -393,4 +404,8 @@ select {
.stat-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.town-summary-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -209,7 +209,9 @@ export type RunState = {
id: string;
campaignId: string;
status: "active" | "paused" | "completed" | "failed";
phase: "dungeon" | "town";
startedAt: string;
lastTownAt?: string;
currentLevel: number;
currentRoomId?: string;
dungeon: DungeonState;