diff --git a/src/App.tsx b/src/App.tsx index 681e4e7..57373d8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 (
@@ -112,14 +123,21 @@ function App() { +
- Run Status - {run.status} + Run Phase + {run.phase}
- {combatReadyEncounter && !run.activeCombat ? ( + {combatReadyEncounter && !run.activeCombat && !inTown ? (
Encounter Ready @@ -175,9 +193,47 @@ function App() {

Run rewards: {run.xpGained} XP earned, {run.defeatedCreatureIds.length} foes defeated.

+

+ {inTown + ? `The party is currently in town${run.lastTownAt ? ` as of ${new Date(run.lastTownAt).toLocaleString()}` : ""}.` + : "The party is still delving below ground."} +

-
+ {inTown ? ( +
+
+

Town Hub

+ Between Delves +
+

Safe Harbor

+

+ 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. +

+
+
+ Current Gold + {run.adventurerSnapshot.inventory.currency.gold} +
+
+ Rooms Found + {currentLevel?.discoveredRoomOrder.length ?? 0} +
+
+ Foes Defeated + {run.defeatedCreatureIds.length} +
+
+
+ +
+
+ ) : ( + <> +

Current Room

Level {run.currentLevel} @@ -318,6 +374,8 @@ function App() {

)}
+ + )}
diff --git a/src/rules/runState.test.ts b/src/rules/runState.test.ts index 6e38b36..35f1583 100644 --- a/src/rules/runState.test.ts +++ b/src/rules/runState.test.ts @@ -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"); }); }); diff --git a/src/rules/runState.ts b/src/rules/runState.ts index 19fffec..072a7de 100644 --- a/src/rules/runState.ts +++ b/src/rules/runState.ts @@ -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."); } diff --git a/src/schemas/state.ts b/src/schemas/state.ts index eb57a3a..3fc0b04 100644 --- a/src/schemas/state.ts +++ b/src/schemas/state.ts @@ -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, diff --git a/src/styles.css b/src/styles.css index a2d350a..02793a0 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; + } } diff --git a/src/types/state.ts b/src/types/state.ts index d10f958..d5e128d 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -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;