✨Feature: implement return to town and resume dungeon flow with state management
This commit is contained in:
66
src/App.tsx
66
src/App.tsx
@@ -9,6 +9,8 @@ import {
|
|||||||
isCurrentRoomCombatReady,
|
isCurrentRoomCombatReady,
|
||||||
resolveRunEnemyTurn,
|
resolveRunEnemyTurn,
|
||||||
resolveRunPlayerTurn,
|
resolveRunPlayerTurn,
|
||||||
|
resumeDungeon,
|
||||||
|
returnToTown,
|
||||||
startCombatInCurrentRoom,
|
startCombatInCurrentRoom,
|
||||||
travelCurrentExit,
|
travelCurrentExit,
|
||||||
} from "@/rules/runState";
|
} from "@/rules/runState";
|
||||||
@@ -54,6 +56,7 @@ function App() {
|
|||||||
const currentRoom = run.currentRoomId ? currentLevel?.rooms[run.currentRoomId] : undefined;
|
const currentRoom = run.currentRoomId ? currentLevel?.rooms[run.currentRoomId] : undefined;
|
||||||
const availableMoves = getAvailableMoves(run);
|
const availableMoves = getAvailableMoves(run);
|
||||||
const combatReadyEncounter = isCurrentRoomCombatReady(run);
|
const combatReadyEncounter = isCurrentRoomCombatReady(run);
|
||||||
|
const inTown = run.phase === "town";
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setRun(createDemoRun());
|
setRun(createDemoRun());
|
||||||
@@ -96,6 +99,14 @@ function App() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReturnToTown = () => {
|
||||||
|
setRun((previous) => returnToTown(previous).run);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResumeDungeon = () => {
|
||||||
|
setRun((previous) => resumeDungeon(previous).run);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-shell">
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
@@ -112,14 +123,21 @@ function App() {
|
|||||||
<button className="button button-primary" onClick={handleReset}>
|
<button className="button button-primary" onClick={handleReset}>
|
||||||
Reset Demo Run
|
Reset Demo Run
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={inTown ? handleResumeDungeon : handleReturnToTown}
|
||||||
|
disabled={Boolean(run.activeCombat)}
|
||||||
|
>
|
||||||
|
{inTown ? "Resume Dungeon" : "Return To Town"}
|
||||||
|
</button>
|
||||||
<div className="status-chip">
|
<div className="status-chip">
|
||||||
<span>Run Status</span>
|
<span>Run Phase</span>
|
||||||
<strong>{run.status}</strong>
|
<strong>{run.phase}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{combatReadyEncounter && !run.activeCombat ? (
|
{combatReadyEncounter && !run.activeCombat && !inTown ? (
|
||||||
<section className="alert-banner">
|
<section className="alert-banner">
|
||||||
<div>
|
<div>
|
||||||
<span className="alert-kicker">Encounter Ready</span>
|
<span className="alert-kicker">Encounter Ready</span>
|
||||||
@@ -175,9 +193,47 @@ function App() {
|
|||||||
<p className="supporting-text">
|
<p className="supporting-text">
|
||||||
Run rewards: {run.xpGained} XP earned, {run.defeatedCreatureIds.length} foes defeated.
|
Run rewards: {run.xpGained} XP earned, {run.defeatedCreatureIds.length} foes defeated.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<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">
|
<div className="panel-header">
|
||||||
<h2>Current Room</h2>
|
<h2>Current Room</h2>
|
||||||
<span>Level {run.currentLevel}</span>
|
<span>Level {run.currentLevel}</span>
|
||||||
@@ -318,6 +374,8 @@ function App() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<article className="panel panel-log">
|
<article className="panel panel-log">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
isCurrentRoomCombatReady,
|
isCurrentRoomCombatReady,
|
||||||
resolveRunEnemyTurn,
|
resolveRunEnemyTurn,
|
||||||
resolveRunPlayerTurn,
|
resolveRunPlayerTurn,
|
||||||
|
resumeDungeon,
|
||||||
|
returnToTown,
|
||||||
startCombatInCurrentRoom,
|
startCombatInCurrentRoom,
|
||||||
travelCurrentExit,
|
travelCurrentExit,
|
||||||
} from "./runState";
|
} from "./runState";
|
||||||
@@ -44,6 +46,7 @@ describe("run state flow", () => {
|
|||||||
|
|
||||||
expect(run.currentLevel).toBe(1);
|
expect(run.currentLevel).toBe(1);
|
||||||
expect(run.currentRoomId).toBe("room.level1.start");
|
expect(run.currentRoomId).toBe("room.level1.start");
|
||||||
|
expect(run.phase).toBe("dungeon");
|
||||||
expect(run.dungeon.levels["1"]?.rooms["room.level1.start"]).toBeDefined();
|
expect(run.dungeon.levels["1"]?.rooms["room.level1.start"]).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -254,22 +257,7 @@ describe("run state flow", () => {
|
|||||||
expect(isCurrentRoomCombatReady(run)).toBe(true);
|
expect(isCurrentRoomCombatReady(run)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists available traversable exits for the current room", () => {
|
it("returns to town and later resumes the dungeon", () => {
|
||||||
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", () => {
|
|
||||||
const run = createRunState({
|
const run = createRunState({
|
||||||
content: sampleContentPack,
|
content: sampleContentPack,
|
||||||
campaignId: "campaign.1",
|
campaignId: "campaign.1",
|
||||||
@@ -277,22 +265,13 @@ describe("run state flow", () => {
|
|||||||
at: "2026-03-15T14:00:00.000Z",
|
at: "2026-03-15T14:00:00.000Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = travelCurrentExit({
|
const inTown = returnToTown(run, "2026-03-15T15:00:00.000Z").run;
|
||||||
content: sampleContentPack,
|
const resumed = resumeDungeon(inTown, "2026-03-15T15:10:00.000Z").run;
|
||||||
run,
|
|
||||||
exitDirection: "north",
|
|
||||||
roller: createSequenceRoller([1, 1]),
|
|
||||||
at: "2026-03-15T14:05:00.000Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.run.currentRoomId).toBe("room.level1.room.002");
|
expect(inTown.phase).toBe("town");
|
||||||
expect(result.run.dungeon.levels["1"]!.discoveredRoomOrder).toEqual([
|
expect(inTown.lastTownAt).toBe("2026-03-15T15:00:00.000Z");
|
||||||
"room.level1.start",
|
expect(getAvailableMoves(inTown)).toEqual([]);
|
||||||
"room.level1.room.002",
|
expect(resumed.phase).toBe("dungeon");
|
||||||
]);
|
expect(resumed.log.at(-1)?.text).toContain("resumed the dungeon delve");
|
||||||
expect(result.run.dungeon.levels["1"]!.rooms["room.level1.room.002"]!.discovery.entered).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(result.run.log[0]?.text).toContain("Travelled north");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,6 +82,22 @@ export type RunTransitionResult = {
|
|||||||
logEntries: LogEntry[];
|
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 {
|
function cloneCombat(combat: CombatState): CombatState {
|
||||||
return {
|
return {
|
||||||
...combat,
|
...combat,
|
||||||
@@ -290,6 +306,7 @@ export function createRunState(options: CreateRunOptions): RunState {
|
|||||||
id: options.runId ?? "run.active",
|
id: options.runId ?? "run.active",
|
||||||
campaignId: options.campaignId,
|
campaignId: options.campaignId,
|
||||||
status: "active",
|
status: "active",
|
||||||
|
phase: "dungeon",
|
||||||
startedAt: at,
|
startedAt: at,
|
||||||
currentLevel: 1,
|
currentLevel: 1,
|
||||||
currentRoomId: "room.level1.start",
|
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(
|
export function enterCurrentRoom(
|
||||||
options: EnterCurrentRoomOptions,
|
options: EnterCurrentRoomOptions,
|
||||||
): RunTransitionResult {
|
): RunTransitionResult {
|
||||||
const run = cloneRun(options.run);
|
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 levelState = requireCurrentLevel(run);
|
||||||
const roomId = requireCurrentRoomId(run);
|
const roomId = requireCurrentRoomId(run);
|
||||||
const entry = enterRoom({
|
const entry = enterRoom({
|
||||||
@@ -334,6 +409,10 @@ export function enterCurrentRoom(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAvailableMoves(run: RunState): AvailableMove[] {
|
export function getAvailableMoves(run: RunState): AvailableMove[] {
|
||||||
|
if (run.phase !== "dungeon") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const room = requireCurrentRoom(run);
|
const room = requireCurrentRoom(run);
|
||||||
|
|
||||||
return room.exits
|
return room.exits
|
||||||
@@ -348,6 +427,10 @@ export function getAvailableMoves(run: RunState): AvailableMove[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isCurrentRoomCombatReady(run: RunState) {
|
export function isCurrentRoomCombatReady(run: RunState) {
|
||||||
|
if (run.phase !== "dungeon") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const room = requireCurrentRoom(run);
|
const room = requireCurrentRoom(run);
|
||||||
|
|
||||||
return Boolean(
|
return Boolean(
|
||||||
@@ -362,6 +445,10 @@ export function travelCurrentExit(
|
|||||||
): RunTransitionResult {
|
): RunTransitionResult {
|
||||||
const run = cloneRun(options.run);
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
if (run.phase !== "dungeon") {
|
||||||
|
throw new Error("Cannot travel while the run is in town.");
|
||||||
|
}
|
||||||
|
|
||||||
if (run.activeCombat) {
|
if (run.activeCombat) {
|
||||||
throw new Error("Cannot travel while combat is active.");
|
throw new Error("Cannot travel while combat is active.");
|
||||||
}
|
}
|
||||||
@@ -437,6 +524,11 @@ export function startCombatInCurrentRoom(
|
|||||||
options: StartCurrentCombatOptions,
|
options: StartCurrentCombatOptions,
|
||||||
): RunTransitionResult {
|
): RunTransitionResult {
|
||||||
const run = cloneRun(options.run);
|
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 levelState = requireCurrentLevel(run);
|
||||||
const roomId = requireCurrentRoomId(run);
|
const roomId = requireCurrentRoomId(run);
|
||||||
const room = levelState.rooms[roomId];
|
const room = levelState.rooms[roomId];
|
||||||
@@ -467,6 +559,10 @@ export function resolveRunPlayerTurn(
|
|||||||
): RunTransitionResult {
|
): RunTransitionResult {
|
||||||
const run = cloneRun(options.run);
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
if (run.phase !== "dungeon") {
|
||||||
|
throw new Error("Cannot resolve combat while the run is in town.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!run.activeCombat) {
|
if (!run.activeCombat) {
|
||||||
throw new Error("Run does not have an active combat.");
|
throw new Error("Run does not have an active combat.");
|
||||||
}
|
}
|
||||||
@@ -517,6 +613,10 @@ export function resolveRunEnemyTurn(
|
|||||||
): RunTransitionResult {
|
): RunTransitionResult {
|
||||||
const run = cloneRun(options.run);
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
if (run.phase !== "dungeon") {
|
||||||
|
throw new Error("Cannot resolve combat while the run is in town.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!run.activeCombat) {
|
if (!run.activeCombat) {
|
||||||
throw new Error("Run does not have an active combat.");
|
throw new Error("Run does not have an active combat.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,9 @@ export const runStateSchema = z.object({
|
|||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
campaignId: z.string().min(1),
|
campaignId: z.string().min(1),
|
||||||
status: z.enum(["active", "paused", "completed", "failed"]),
|
status: z.enum(["active", "paused", "completed", "failed"]),
|
||||||
|
phase: z.enum(["dungeon", "town"]),
|
||||||
startedAt: z.string().min(1),
|
startedAt: z.string().min(1),
|
||||||
|
lastTownAt: z.string().optional(),
|
||||||
currentLevel: z.number().int().positive(),
|
currentLevel: z.number().int().positive(),
|
||||||
currentRoomId: z.string().optional(),
|
currentRoomId: z.string().optional(),
|
||||||
dungeon: dungeonStateSchema,
|
dungeon: dungeonStateSchema,
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ select {
|
|||||||
grid-column: span 12;
|
grid-column: span 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-town-hub {
|
||||||
|
grid-column: span 8;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -217,6 +221,13 @@ select {
|
|||||||
color: rgba(244, 239, 227, 0.76);
|
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 {
|
.room-title {
|
||||||
margin: 0 0 0.35rem;
|
margin: 0 0 0.35rem;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
@@ -393,4 +404,8 @@ select {
|
|||||||
.stat-strip {
|
.stat-strip {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.town-summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,7 +209,9 @@ export type RunState = {
|
|||||||
id: string;
|
id: string;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
status: "active" | "paused" | "completed" | "failed";
|
status: "active" | "paused" | "completed" | "failed";
|
||||||
|
phase: "dungeon" | "town";
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
|
lastTownAt?: string;
|
||||||
currentLevel: number;
|
currentLevel: number;
|
||||||
currentRoomId?: string;
|
currentRoomId?: string;
|
||||||
dungeon: DungeonState;
|
dungeon: DungeonState;
|
||||||
|
|||||||
Reference in New Issue
Block a user