Add campaign shell and map generation recovery

This commit is contained in:
Keith Solomon
2026-03-18 21:35:53 -05:00
parent bcd720cae8
commit 37e2b27870
11 changed files with 1012 additions and 64 deletions
+165 -51
View File
@@ -3,15 +3,20 @@ import React from "react";
import { sampleContentPack } from "@/data/sampleContentPack";
import { createStartingAdventurer } from "@/rules/character";
import {
deleteSavedRun,
buildCampaignSaveLabel,
deleteSavedCampaignSession,
exportCampaignSession,
getBrowserStorage,
listSavedRuns,
loadSavedRun,
saveRun,
type SavedRunSummary,
importCampaignSession,
listSavedCampaigns,
loadSavedCampaignSession,
saveCampaignSession,
type SavedCampaignSummary,
} from "@/rules/persistence";
import { createCampaignSession, updateSessionRun, type CampaignSession } from "@/rules/campaign";
import {
createRunState,
canCompleteCurrentLevel,
completeCurrentLevel,
enterCurrentRoom,
getAvailableMoves,
isCurrentRoomCombatReady,
@@ -39,7 +44,7 @@ import {
import { useTownService } from "@/rules/townServices";
import type { RunState } from "@/types/state";
function createDemoRun() {
function createDemoSession() {
const adventurer = createStartingAdventurer(sampleContentPack, {
name: "Aster",
weaponId: "weapon.short-sword",
@@ -47,9 +52,8 @@ function createDemoRun() {
scrollId: "scroll.lesser-heal",
});
return createRunState({
return createCampaignSession({
content: sampleContentPack,
campaignId: "campaign.demo",
adventurer,
});
}
@@ -103,12 +107,15 @@ function getCombatTargetNumber(enemyArmourValue = 0) {
}
function App() {
const [run, setRun] = React.useState<RunState>(() => createDemoRun());
const [savedRuns, setSavedRuns] = React.useState<SavedRunSummary[]>([]);
const [session, setSession] = React.useState<CampaignSession>(() => createDemoSession());
const [savedCampaigns, setSavedCampaigns] = React.useState<SavedCampaignSummary[]>([]);
const run = session.run;
const campaign = session.campaign;
const currentLevel = run.dungeon.levels[run.currentLevel];
const currentRoom = run.currentRoomId ? currentLevel?.rooms[run.currentRoomId] : undefined;
const availableMoves = getAvailableMoves(run);
const combatReadyEncounter = isCurrentRoomCombatReady(run);
const levelCompletionReady = canCompleteCurrentLevel(run);
const inTown = run.phase === "town";
const knownServices = sampleContentPack.townServices.filter((service) =>
run.townState.knownServices.includes(service.id),
@@ -140,35 +147,56 @@ function App() {
return;
}
setSavedRuns(listSavedRuns(storage));
setSavedCampaigns(listSavedCampaigns(storage));
}, []);
const handleReset = () => {
setRun(createDemoRun());
setSession(createDemoSession());
};
const refreshSavedRuns = React.useCallback(() => {
const refreshSavedCampaigns = React.useCallback(() => {
const storage = getBrowserStorage();
if (!storage) {
return;
}
setSavedRuns(listSavedRuns(storage));
setSavedCampaigns(listSavedCampaigns(storage));
}, []);
const updateRun = React.useCallback(
(transform: (currentRun: RunState) => RunState) => {
setSession((previous) => updateSessionRun(sampleContentPack, previous, transform(previous.run)));
},
[],
);
React.useEffect(() => {
const storage = getBrowserStorage();
if (!storage) {
return;
}
saveCampaignSession(storage, session, {
saveId: `campaign.${campaign.id}.autosave`,
label: `${buildCampaignSaveLabel(session)} · autosave`,
});
setSavedCampaigns(listSavedCampaigns(storage));
}, [campaign.id, session]);
const handleEnterRoom = () => {
setRun((previous) => enterCurrentRoom({ content: sampleContentPack, run: previous }).run);
updateRun((previous) => enterCurrentRoom({ content: sampleContentPack, run: previous }).run);
};
const handleStartCombat = () => {
setRun((previous) =>
updateRun((previous) =>
startCombatInCurrentRoom({ content: sampleContentPack, run: previous }).run,
);
};
const handleTravel = (direction: "north" | "east" | "south" | "west") => {
setRun((previous) =>
updateRun((previous) =>
travelCurrentExit({
content: sampleContentPack,
run: previous,
@@ -178,7 +206,7 @@ function App() {
};
const handlePlayerTurn = (manoeuvreId: string, targetEnemyId: string) => {
setRun((previous) =>
updateRun((previous) =>
resolveRunPlayerTurn({
content: sampleContentPack,
run: previous,
@@ -189,21 +217,25 @@ function App() {
};
const handleEnemyTurn = () => {
setRun((previous) =>
updateRun((previous) =>
resolveRunEnemyTurn({ content: sampleContentPack, run: previous }).run,
);
};
const handleReturnToTown = () => {
setRun((previous) => returnToTown(previous).run);
updateRun((previous) => returnToTown(previous).run);
};
const handleCompleteLevel = () => {
updateRun((previous) => completeCurrentLevel(previous).run);
};
const handleResumeDungeon = () => {
setRun((previous) => resumeDungeon(previous).run);
updateRun((previous) => resumeDungeon(previous).run);
};
const handleUseTownService = (serviceId: string) => {
setRun((previous) =>
updateRun((previous) =>
useTownService({
content: sampleContentPack,
run: previous,
@@ -213,7 +245,7 @@ function App() {
};
const handleGrantTreasure = (definitionId: string) => {
setRun((previous) =>
updateRun((previous) =>
grantDebugTreasure({
content: sampleContentPack,
run: previous,
@@ -223,7 +255,7 @@ function App() {
};
const handleStashTreasure = (definitionId: string) => {
setRun((previous) =>
updateRun((previous) =>
stashCarriedTreasure({
content: sampleContentPack,
run: previous,
@@ -233,7 +265,7 @@ function App() {
};
const handleWithdrawTreasure = (definitionId: string) => {
setRun((previous) =>
updateRun((previous) =>
withdrawStashedTreasure({
content: sampleContentPack,
run: previous,
@@ -243,7 +275,7 @@ function App() {
};
const handleQueueTreasure = (definitionId: string, source: "carried" | "stash") => {
setRun((previous) =>
updateRun((previous) =>
queueTreasureForSale({
content: sampleContentPack,
run: previous,
@@ -254,7 +286,7 @@ function App() {
};
const handleSellPending = () => {
setRun((previous) =>
updateRun((previous) =>
sellPendingTreasure({
content: sampleContentPack,
run: previous,
@@ -263,7 +295,7 @@ function App() {
};
const handleUsePotion = () => {
setRun((previous) =>
updateRun((previous) =>
usePotion({
content: sampleContentPack,
run: previous,
@@ -273,7 +305,7 @@ function App() {
};
const handleUseScroll = () => {
setRun((previous) =>
updateRun((previous) =>
useScroll({
content: sampleContentPack,
run: previous,
@@ -284,7 +316,7 @@ function App() {
};
const handleRationRest = () => {
setRun((previous) =>
updateRun((previous) =>
restWithRation({
content: sampleContentPack,
run: previous,
@@ -293,26 +325,26 @@ function App() {
);
};
const handleSaveRun = () => {
const handleSaveCampaign = () => {
const storage = getBrowserStorage();
if (!storage) {
return;
}
saveRun(storage, run);
refreshSavedRuns();
saveCampaignSession(storage, session);
refreshSavedCampaigns();
};
const handleLoadRun = (saveId: string) => {
const handleLoadCampaign = (saveId: string) => {
const storage = getBrowserStorage();
if (!storage) {
return;
}
setRun(loadSavedRun(storage, saveId));
refreshSavedRuns();
setSession(loadSavedCampaignSession(storage, saveId));
refreshSavedCampaigns();
};
const handleDeleteSave = (saveId: string) => {
@@ -322,7 +354,31 @@ function App() {
return;
}
setSavedRuns(deleteSavedRun(storage, saveId));
setSavedCampaigns(deleteSavedCampaignSession(storage, saveId));
};
const handleExportCampaign = () => {
const blob = new Blob([exportCampaignSession(session)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${campaign.id}.json`;
link.click();
URL.revokeObjectURL(url);
};
const handleImportCampaign = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
const imported = importCampaignSession(await file.text());
setSession(imported);
refreshSavedCampaigns();
event.target.value = "";
};
return (
@@ -330,20 +386,27 @@ function App() {
<section className="hero">
<div>
<p className="eyebrow">2D6 Dungeon Web</p>
<h1>Dungeon Loop Shell</h1>
<h1>Campaign Command Table</h1>
<p className="lede">
Traverse generated rooms, auto-resolve room entry, and engage combat
when a room reveals a real encounter.
Keep the active delve, town ledger, and campaign record in one place while you
explore Level 1 and carry progress forward between sessions.
</p>
</div>
<div className="hero-actions">
<button className="button button-primary" onClick={handleReset}>
Reset Demo Run
Reset Demo Campaign
</button>
<button className="button" onClick={handleSaveRun}>
Save Run
<button className="button" onClick={handleSaveCampaign}>
Save Campaign
</button>
<button className="button" onClick={handleExportCampaign}>
Export JSON
</button>
<label className="button button-file">
Import JSON
<input type="file" accept="application/json" onChange={handleImportCampaign} />
</label>
<button
className="button"
onClick={inTown ? handleResumeDungeon : handleReturnToTown}
@@ -352,7 +415,7 @@ function App() {
{inTown ? "Resume Dungeon" : "Return To Town"}
</button>
<div className="status-chip">
<span>Run Phase</span>
<span>Campaign Phase</span>
<strong>{run.phase}</strong>
</div>
</div>
@@ -407,6 +470,39 @@ function App() {
) : null}
<section className="dashboard-grid">
<article className="panel">
<div className="panel-header">
<h2>Campaign Ledger</h2>
<span>{campaign.id}</span>
</div>
<div className="town-summary-grid">
<div className="encounter-box">
<span className="encounter-label">Adventurer</span>
<strong>{campaign.adventurer.name}</strong>
</div>
<div className="encounter-box">
<span className="encounter-label">Run History</span>
<strong>{campaign.runHistory.length}</strong>
</div>
<div className="encounter-box">
<span className="encounter-label">Town Visits</span>
<strong>{campaign.townState.visits}</strong>
</div>
<div className="encounter-box">
<span className="encounter-label">Updated</span>
<strong>{new Date(campaign.updatedAt).toLocaleTimeString()}</strong>
</div>
</div>
<div className="room-meta">
<span>Completed Levels: {campaign.completedLevels.join(", ") || "None"}</span>
<span>Unlocked Levels: {campaign.unlockedLevels.join(", ")}</span>
</div>
<p className="supporting-text">
Campaign saves include the adventurer sheet, town state, run history snapshot, and
the active dungeon delve.
</p>
</article>
<article className="panel panel-highlight">
<div className="panel-header">
<h2>Adventurer</h2>
@@ -518,24 +614,29 @@ function App() {
<article className="panel panel-saves">
<div className="panel-header">
<h2>Save Archive</h2>
<span>{savedRuns.length} saves</span>
<h2>Campaign Archive</h2>
<span>{savedCampaigns.length} saves</span>
</div>
{savedRuns.length === 0 ? (
<p className="supporting-text">No saved runs yet. Save the current run to persist progress.</p>
{savedCampaigns.length === 0 ? (
<p className="supporting-text">
No saved campaigns yet. The current campaign autosaves as you play, and you can
archive manual snapshots here.
</p>
) : (
<div className="save-list">
{savedRuns.map((save) => (
{savedCampaigns.map((save) => (
<article key={save.id} className="save-card">
<div>
<span className="encounter-label">{save.phase}</span>
<strong>{save.label}</strong>
<p className="supporting-text">
Saved {new Date(save.savedAt).toLocaleString()} · Level {save.currentLevel}
{" · "}
Town visits {save.visits}
</p>
</div>
<div className="save-actions">
<button className="button" onClick={() => handleLoadRun(save.id)}>
<button className="button" onClick={() => handleLoadCampaign(save.id)}>
Load
</button>
<button className="button" onClick={() => handleDeleteSave(save.id)}>
@@ -733,11 +834,24 @@ function App() {
>
Start Combat
</button>
<button
className="button"
onClick={handleCompleteLevel}
disabled={!levelCompletionReady}
>
Complete Level
</button>
</div>
<div className="encounter-box">
<span className="encounter-label">Encounter</span>
<strong>{currentRoom?.encounter?.resultLabel ?? "None"}</strong>
</div>
{levelCompletionReady ? (
<p className="supporting-text">
This room qualifies as the final cleared chamber. Completing the level will reveal
stairs down, record the victory, and return you to town.
</p>
) : null}
</article>
<article className="panel">
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { sampleContentPack } from "@/data/sampleContentPack";
import { createStartingAdventurer } from "./character";
import { createCampaignSession, summarizeRun, syncCampaignFromRun, updateSessionRun } from "./campaign";
import { returnToTown } from "./runState";
function createAdventurer() {
return createStartingAdventurer(sampleContentPack, {
name: "Aster",
weaponId: "weapon.short-sword",
armourId: "armour.leather-vest",
scrollId: "scroll.lesser-heal",
});
}
describe("campaign session", () => {
it("creates a synced campaign and run together", () => {
const session = createCampaignSession({
content: sampleContentPack,
adventurer: createAdventurer(),
campaignId: "campaign.test",
at: "2026-03-18T20:00:00.000Z",
});
expect(session.campaign.id).toBe("campaign.test");
expect(session.campaign.adventurer.name).toBe("Aster");
expect(session.campaign.runHistory[0]?.runId).toBe(session.run.id);
});
it("syncs campaign state from an updated run", () => {
const session = createCampaignSession({
content: sampleContentPack,
adventurer: createAdventurer(),
});
const nextRun = returnToTown(session.run, "2026-03-18T20:10:00.000Z").run;
const synced = syncCampaignFromRun(
sampleContentPack,
session.campaign,
nextRun,
"2026-03-18T20:10:00.000Z",
);
expect(synced.townState.visits).toBe(1);
expect(synced.updatedAt).toBe("2026-03-18T20:10:00.000Z");
expect(synced.runHistory[0]?.outcome).toBe("saved-in-progress");
});
it("updates a session run and campaign together", () => {
const session = createCampaignSession({
content: sampleContentPack,
adventurer: createAdventurer(),
});
const updated = updateSessionRun(
sampleContentPack,
session,
returnToTown(session.run, "2026-03-18T20:10:00.000Z").run,
"2026-03-18T20:10:00.000Z",
);
expect(updated.run.phase).toBe("town");
expect(updated.campaign.townState.visits).toBe(1);
});
it("promotes completed and unlocked levels from run flags", () => {
const session = createCampaignSession({
content: sampleContentPack,
adventurer: createAdventurer(),
});
const nextRun = {
...session.run,
dungeon: {
...session.run.dungeon,
globalFlags: ["level:1:completed"],
},
};
const synced = syncCampaignFromRun(
sampleContentPack,
session.campaign,
nextRun,
"2026-03-18T20:15:00.000Z",
);
expect(synced.completedLevels).toContain(1);
expect(synced.unlockedLevels).toContain(2);
});
it("summarizes failed runs as defeats", () => {
const session = createCampaignSession({
content: sampleContentPack,
adventurer: createAdventurer(),
});
const summary = summarizeRun({
...session.run,
status: "failed",
});
expect(summary.outcome).toBe("defeated");
});
});
+156
View File
@@ -0,0 +1,156 @@
import type { ContentPack } from "@/types/content";
import type { CampaignState, RunState, RunSummary } from "@/types/state";
import { createRunState } from "./runState";
export const RULES_VERSION = "0.1.0";
export type CampaignSession = {
campaign: CampaignState;
run: RunState;
};
export type CreateCampaignSessionOptions = {
content: ContentPack;
adventurer: CampaignState["adventurer"];
at?: string;
campaignId?: string;
runId?: string;
rulesVersion?: string;
};
function dedupeNumbers(values: number[]) {
return [...new Set(values)].sort((left, right) => left - right);
}
function getCompletedLevels(run: RunState) {
return run.dungeon.globalFlags
.map((flag) => /^level:(\d+):completed$/.exec(flag)?.[1])
.filter((value): value is string => Boolean(value))
.map((value) => Number.parseInt(value, 10))
.filter((value) => Number.isFinite(value));
}
function inferRunOutcome(run: RunState): RunSummary["outcome"] {
if (run.status === "failed") {
return "defeated";
}
if (run.status === "completed") {
return "escaped";
}
return "saved-in-progress";
}
export function summarizeRun(run: RunState, endedAt?: string): RunSummary {
const roomsVisited = Object.values(run.dungeon.levels).reduce(
(total, level) =>
total + Object.values(level.rooms).filter((room) => room.discovery.entered).length,
0,
);
const treasureValue = run.lootedItems.reduce((total, item) => total + item.quantity, 0);
return {
runId: run.id,
startedAt: run.startedAt,
endedAt,
deepestLevel: run.currentLevel,
roomsVisited,
creaturesDefeated: [...run.defeatedCreatureIds],
xpGained: run.xpGained,
treasureValue,
outcome: inferRunOutcome(run),
};
}
function upsertRunSummary(runHistory: RunSummary[], summary: RunSummary) {
const nextHistory = runHistory.filter((entry) => entry.runId !== summary.runId);
nextHistory.unshift(summary);
return nextHistory;
}
export function createCampaignFromRun(
content: ContentPack,
run: RunState,
options?: {
at?: string;
rulesVersion?: string;
},
): CampaignState {
const at = options?.at ?? run.startedAt;
return {
id: run.campaignId,
createdAt: at,
updatedAt: at,
rulesVersion: options?.rulesVersion ?? RULES_VERSION,
contentVersion: content.version,
adventurer: structuredClone(run.adventurerSnapshot),
unlockedLevels: [1],
completedLevels: [],
townState: structuredClone(run.townState),
questState: [],
campaignFlags: [],
runHistory: [summarizeRun(run)],
};
}
export function syncCampaignFromRun(
content: ContentPack,
campaign: CampaignState,
run: RunState,
at = new Date().toISOString(),
): CampaignState {
const completedLevels = dedupeNumbers([...campaign.completedLevels, ...getCompletedLevels(run)]);
const unlockedLevels = dedupeNumbers([
...campaign.unlockedLevels,
run.currentLevel,
...completedLevels,
...completedLevels.map((level) => level + 1),
]);
return {
...structuredClone(campaign),
updatedAt: at,
contentVersion: content.version,
adventurer: structuredClone(run.adventurerSnapshot),
townState: structuredClone(run.townState),
unlockedLevels,
completedLevels,
runHistory: upsertRunSummary(campaign.runHistory, summarizeRun(run)),
};
}
export function createCampaignSession(
options: CreateCampaignSessionOptions,
): CampaignSession {
const run = createRunState({
content: options.content,
adventurer: options.adventurer,
campaignId: options.campaignId ?? "campaign.demo",
runId: options.runId,
at: options.at,
});
const campaign = createCampaignFromRun(options.content, run, {
at: options.at,
rulesVersion: options.rulesVersion,
});
return {
campaign,
run,
};
}
export function updateSessionRun(
content: ContentPack,
session: CampaignSession,
nextRun: RunState,
at = new Date().toISOString(),
): CampaignSession {
return {
run: nextRun,
campaign: syncCampaignFromRun(content, session.campaign, nextRun, at),
};
}
+14
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { sampleContentPack } from "@/data/sampleContentPack";
import {
addSecretDoorFallback,
expandLevelFromExit,
getUnresolvedExits,
initializeDungeonLevel,
@@ -84,4 +85,17 @@ describe("dungeon state", () => {
}),
).toThrow("already connected");
});
it("adds a fallback secret exit when progression stalls", () => {
const levelState = initializeDungeonLevel({ content: sampleContentPack });
const room = levelState.rooms["room.level1.start"]!;
room.exits = [];
const fallback = addSecretDoorFallback(levelState);
expect(fallback.levelState.secretDoorUsed).toBe(true);
expect(fallback.room.id).toBe("room.level1.start");
expect(fallback.exit.exitType).toBe("secret");
});
});
+162
View File
@@ -28,6 +28,17 @@ export type ExpansionResult = {
fromRoom: RoomState;
};
export type PlaceStairsDownResult = {
levelState: DungeonLevelState;
room: RoomState;
};
export type SecretDoorFallbackResult = {
levelState: DungeonLevelState;
room: RoomState;
exit: RoomExitState;
};
const DIRECTION_VECTORS: Record<CardinalDirection, { x: number; y: number }> = {
north: { x: 0, y: -1 },
east: { x: 1, y: 0 },
@@ -77,6 +88,16 @@ function cloneLevel(levelState: DungeonLevelState): DungeonLevelState {
};
}
function getAvailableStairsDirection(room: RoomState): CardinalDirection {
const usedDirections = new Set(room.exits.map((exit) => exit.direction));
return (
(["north", "east", "south", "west"] as const).find(
(direction) => !usedDirections.has(direction),
) ?? "north"
);
}
function findExit(room: RoomState, direction: CardinalDirection): RoomExitState {
const exit = room.exits.find((candidate) => candidate.direction === direction);
@@ -96,6 +117,12 @@ function computeNextPosition(room: RoomState, direction: CardinalDirection) {
};
}
function isCoordinateOccupied(levelState: DungeonLevelState, position: { x: number; y: number }) {
return Object.values(levelState.rooms).some(
(room) => room.position.x === position.x && room.position.y === position.y,
);
}
function connectRooms(
fromRoom: RoomState,
toRoom: RoomState,
@@ -135,6 +162,18 @@ function assertCoordinateAvailable(levelState: DungeonLevelState, position: { x:
}
}
function getLegalNewExitDirections(levelState: DungeonLevelState, room: RoomState) {
const usedDirections = new Set(room.exits.map((exit) => exit.direction));
return (["north", "east", "south", "west"] as const).filter((direction) => {
if (usedDirections.has(direction)) {
return false;
}
return !isCoordinateOccupied(levelState, computeNextPosition(room, direction));
});
}
export function initializeDungeonLevel(
options: InitializeLevelOptions,
): DungeonLevelState {
@@ -217,3 +256,126 @@ export function expandLevelFromExit(
fromRoom,
};
}
export function canPlaceStairsDown(
levelState: DungeonLevelState,
roomId: string,
) {
const room = levelState.rooms[roomId];
if (!room) {
return false;
}
if (levelState.stairsDownRoomId) {
return false;
}
if (!room.discovery.cleared) {
return false;
}
if (getUnresolvedExits(levelState).length > 0) {
return false;
}
return !Object.values(levelState.rooms).some(
(candidate) =>
candidate.id !== roomId &&
candidate.roomClass !== "start" &&
candidate.roomClass !== "stairs" &&
!candidate.discovery.cleared,
);
}
export function placeStairsDown(
levelState: DungeonLevelState,
roomId: string,
): PlaceStairsDownResult {
if (!canPlaceStairsDown(levelState, roomId)) {
throw new Error(`Cannot place stairs down in room ${roomId}.`);
}
const nextLevelState = cloneLevel(levelState);
const room = nextLevelState.rooms[roomId];
if (!room) {
throw new Error(`Unknown room id: ${roomId}`);
}
if (!room.exits.some((exit) => exit.exitType === "stairs")) {
room.exits.push({
id: `${room.id}.exit.${room.exits.length + 1}`,
direction: getAvailableStairsDirection(room),
exitType: "stairs",
discovered: true,
traversable: true,
destinationLevel: levelState.level + 1,
});
}
if (!room.flags.includes("stairs-down")) {
room.flags.push("stairs-down");
}
if (!room.notes.some((note) => note.includes("stairs"))) {
room.notes.push(`A stairway descends toward level ${levelState.level + 1}.`);
}
nextLevelState.rooms[roomId] = room;
nextLevelState.stairsDownRoomId = roomId;
return {
levelState: nextLevelState,
room,
};
}
export function addSecretDoorFallback(
levelState: DungeonLevelState,
): SecretDoorFallbackResult {
if (levelState.secretDoorUsed) {
throw new Error("Secret door fallback has already been used on this level.");
}
const nextLevelState = cloneLevel(levelState);
for (const roomId of [...nextLevelState.discoveredRoomOrder].reverse()) {
const room = nextLevelState.rooms[roomId];
if (!room) {
continue;
}
const direction = getLegalNewExitDirections(nextLevelState, room)[0];
if (!direction) {
continue;
}
const exit: RoomExitState = {
id: `${room.id}.exit.${room.exits.length + 1}`,
direction,
exitType: "secret",
discovered: true,
traversable: true,
};
room.exits.push(exit);
if (!room.flags.includes("fallback-secret-exit")) {
room.flags.push("fallback-secret-exit");
}
nextLevelState.rooms[roomId] = room;
nextLevelState.secretDoorUsed = true;
return {
levelState: nextLevelState,
room,
exit,
};
}
throw new Error("No eligible room could host a fallback secret door.");
}
+58
View File
@@ -3,11 +3,18 @@ import { describe, expect, it } from "vitest";
import { sampleContentPack } from "@/data/sampleContentPack";
import { createStartingAdventurer } from "./character";
import { createCampaignSession } from "./campaign";
import {
deleteSavedCampaignSession,
deleteSavedRun,
exportCampaignSession,
importCampaignSession,
listSavedCampaigns,
loadSavedRun,
loadSavedCampaignSession,
saveRun,
listSavedRuns,
saveCampaignSession,
type StorageLike,
} from "./persistence";
import { createRunState, returnToTown } from "./runState";
@@ -100,3 +107,54 @@ describe("run persistence", () => {
expect(listSavedRuns(storage)).toEqual([]);
});
});
describe("campaign persistence", () => {
it("saves and loads a campaign session", () => {
const storage = createMemoryStorage();
const session = createCampaignSession({
content: sampleContentPack,
adventurer: createAdventurer(),
campaignId: "campaign.1",
at: "2026-03-18T23:00:00.000Z",
});
saveCampaignSession(storage, session, {
saveId: "campaign.one",
savedAt: "2026-03-18T23:00:00.000Z",
});
const loaded = loadSavedCampaignSession(storage, "campaign.one");
expect(loaded.campaign.id).toBe("campaign.1");
expect(loaded.run.adventurerSnapshot.name).toBe("Aster");
});
it("lists and deletes campaign saves", () => {
const storage = createMemoryStorage();
const session = createCampaignSession({
content: sampleContentPack,
adventurer: createAdventurer(),
});
saveCampaignSession(storage, session, {
saveId: "campaign.one",
savedAt: "2026-03-18T23:00:00.000Z",
});
expect(listSavedCampaigns(storage)).toHaveLength(1);
expect(deleteSavedCampaignSession(storage, "campaign.one")).toEqual([]);
});
it("exports and imports campaign json", () => {
const session = createCampaignSession({
content: sampleContentPack,
adventurer: createAdventurer(),
});
const exported = exportCampaignSession(session);
const imported = importCampaignSession(exported);
expect(imported.campaign.id).toBe(session.campaign.id);
expect(imported.run.id).toBe(session.run.id);
});
});
+142 -1
View File
@@ -1,7 +1,8 @@
import { z } from "zod";
import { runStateSchema } from "@/schemas/state";
import { campaignStateSchema, runStateSchema } from "@/schemas/state";
import type { RunState } from "@/types/state";
import type { CampaignSession } from "./campaign";
export type StorageLike = {
getItem(key: string): string | null;
@@ -26,7 +27,26 @@ export type SavedRunSummary = {
adventurerName: string;
};
export type SavedCampaignRecord = {
id: string;
label: string;
savedAt: string;
session: CampaignSession;
};
export type SavedCampaignSummary = {
id: string;
label: string;
savedAt: string;
campaignId: string;
adventurerName: string;
currentLevel: number;
phase: RunState["phase"];
visits: number;
};
const STORAGE_KEY = "d2d6-dungeon.run-saves.v1";
const CAMPAIGN_STORAGE_KEY = "d2d6-dungeon.campaign-saves.v1";
const savedRunRecordSchema = z.object({
id: z.string().min(1),
@@ -36,6 +56,16 @@ const savedRunRecordSchema = z.object({
});
const savedRunRecordListSchema = z.array(savedRunRecordSchema);
const savedCampaignRecordSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
savedAt: z.string().min(1),
session: z.object({
campaign: campaignStateSchema,
run: runStateSchema,
}),
});
const savedCampaignRecordListSchema = z.array(savedCampaignRecordSchema);
function readSaveRecords(storage: StorageLike): SavedRunRecord[] {
const raw = storage.getItem(STORAGE_KEY);
@@ -52,6 +82,21 @@ function writeSaveRecords(storage: StorageLike, records: SavedRunRecord[]) {
storage.setItem(STORAGE_KEY, JSON.stringify(records));
}
function readCampaignRecords(storage: StorageLike): SavedCampaignRecord[] {
const raw = storage.getItem(CAMPAIGN_STORAGE_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as unknown;
return savedCampaignRecordListSchema.parse(parsed);
}
function writeCampaignRecords(storage: StorageLike, records: SavedCampaignRecord[]) {
storage.setItem(CAMPAIGN_STORAGE_KEY, JSON.stringify(records));
}
function toSummary(record: SavedRunRecord): SavedRunSummary {
return {
id: record.id,
@@ -64,11 +109,28 @@ function toSummary(record: SavedRunRecord): SavedRunSummary {
};
}
function toCampaignSummary(record: SavedCampaignRecord): SavedCampaignSummary {
return {
id: record.id,
label: record.label,
savedAt: record.savedAt,
campaignId: record.session.campaign.id,
adventurerName: record.session.campaign.adventurer.name,
currentLevel: record.session.run.currentLevel,
phase: record.session.run.phase,
visits: record.session.campaign.townState.visits,
};
}
export function buildSaveLabel(run: RunState) {
const roomLabel = run.currentRoomId ?? "unknown-room";
return `${run.adventurerSnapshot.name} · L${run.currentLevel} · ${run.phase} · ${roomLabel}`;
}
export function buildCampaignSaveLabel(session: CampaignSession) {
return `${session.campaign.adventurer.name} · L${session.run.currentLevel} · ${session.run.phase} · ${session.campaign.runHistory.length} log`;
}
export function listSavedRuns(storage: StorageLike): SavedRunSummary[] {
return readSaveRecords(storage)
.sort((left, right) => right.savedAt.localeCompare(left.savedAt))
@@ -128,3 +190,82 @@ export function getBrowserStorage(): StorageLike | null {
return window.localStorage;
}
export function listSavedCampaigns(storage: StorageLike): SavedCampaignSummary[] {
return readCampaignRecords(storage)
.sort((left, right) => right.savedAt.localeCompare(left.savedAt))
.map(toCampaignSummary);
}
export function saveCampaignSession(
storage: StorageLike,
session: CampaignSession,
options?: {
saveId?: string;
label?: string;
savedAt?: string;
},
): SavedCampaignSummary {
const savedAt = options?.savedAt ?? new Date().toISOString();
const id = options?.saveId ?? `campaign-save.${savedAt}`;
const label = options?.label ?? buildCampaignSaveLabel(session);
const record = savedCampaignRecordSchema.parse({
id,
label,
savedAt,
session,
});
const existing = readCampaignRecords(storage).filter((entry) => entry.id !== id);
existing.unshift(record);
writeCampaignRecords(storage, existing);
return toCampaignSummary(record);
}
export function loadSavedCampaignSession(storage: StorageLike, saveId: string): CampaignSession {
const record = readCampaignRecords(storage).find((entry) => entry.id === saveId);
if (!record) {
throw new Error(`Unknown campaign save id: ${saveId}`);
}
return record.session;
}
export function deleteSavedCampaignSession(
storage: StorageLike,
saveId: string,
): SavedCampaignSummary[] {
const records = readCampaignRecords(storage).filter((entry) => entry.id !== saveId);
writeCampaignRecords(storage, records);
return records
.sort((left, right) => right.savedAt.localeCompare(left.savedAt))
.map(toCampaignSummary);
}
export function exportCampaignSession(session: CampaignSession) {
return JSON.stringify(
{
exportedAt: new Date().toISOString(),
session,
},
null,
2,
);
}
export function importCampaignSession(serialized: string): CampaignSession {
const parsed = JSON.parse(serialized) as unknown;
const importSchema = z.object({
exportedAt: z.string().min(1),
session: z.object({
campaign: campaignStateSchema,
run: runStateSchema,
}),
});
return importSchema.parse(parsed).session;
}
+14 -1
View File
@@ -40,6 +40,18 @@ const DEFAULT_ROOM_DIMENSIONS: Record<RoomClass, { width: number; height: number
const DEFAULT_DIRECTIONS = ["north", "east", "south", "west"] as const;
function getDirectionSeed(roomId: string) {
return Array.from(roomId).reduce((total, char) => total + char.charCodeAt(0), 0);
}
function getDirectionOrder(roomId: string) {
const rotation = getDirectionSeed(roomId) % DEFAULT_DIRECTIONS.length;
return [
...DEFAULT_DIRECTIONS.slice(rotation),
...DEFAULT_DIRECTIONS.slice(0, rotation),
];
}
function inferExitType(exitHint?: string): ExitType {
const normalized = exitHint?.toLowerCase() ?? "";
@@ -91,8 +103,9 @@ function createExits(
): RoomExitState[] {
const exitCount = inferExitCount(roomClass, exitHint);
const exitType = inferExitType(exitHint);
const directionOrder = getDirectionOrder(roomId);
return DEFAULT_DIRECTIONS.slice(0, exitCount).map((direction, index) => ({
return directionOrder.slice(0, exitCount).map((direction, index) => ({
id: `${roomId}.exit.${index + 1}`,
direction,
exitType,
+47
View File
@@ -4,6 +4,8 @@ import { sampleContentPack } from "@/data/sampleContentPack";
import { createStartingAdventurer } from "./character";
import {
canCompleteCurrentLevel,
completeCurrentLevel,
createRunState,
enterCurrentRoom,
getAvailableMoves,
@@ -68,6 +70,29 @@ describe("run state flow", () => {
expect(result.run.log[0]?.text).toContain("Re-entered Entry Chamber");
});
it("reveals a fallback secret exit when room entry would otherwise stall progression", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
at: "2026-03-15T14:00:00.000Z",
});
run.dungeon.levels["1"]!.rooms["room.level1.start"]!.exits = [];
const result = enterCurrentRoom({
content: sampleContentPack,
run,
at: "2026-03-15T14:01:00.000Z",
});
expect(result.run.dungeon.levels["1"]!.secretDoorUsed).toBe(true);
expect(result.run.dungeon.levels["1"]!.rooms["room.level1.start"]!.exits).toEqual(
expect.arrayContaining([expect.objectContaining({ exitType: "secret" })]),
);
expect(result.run.log.at(-1)?.text).toContain("secret exit");
});
it("starts combat from the current room and stores the active combat state", () => {
const run = createRunState({
content: sampleContentPack,
@@ -364,4 +389,26 @@ describe("run state flow", () => {
expect(resumed.phase).toBe("dungeon");
expect(resumed.log.at(-1)?.text).toContain("resumed the dungeon delve");
});
it("places stairs and completes the current level when the map is exhausted", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
at: "2026-03-15T14:00:00.000Z",
});
const room = run.dungeon.levels["1"]!.rooms["room.level1.start"]!;
room.discovery.cleared = true;
room.exits = [];
expect(canCompleteCurrentLevel(run)).toBe(true);
const result = completeCurrentLevel(run, "2026-03-15T15:30:00.000Z");
expect(result.run.phase).toBe("town");
expect(result.run.dungeon.levels["1"]!.stairsDownRoomId).toBe("room.level1.start");
expect(result.run.dungeon.globalFlags).toContain("level:1:completed");
expect(result.run.log.at(-1)?.text).toContain("Returned to town");
});
});
+136 -11
View File
@@ -19,9 +19,12 @@ import {
type ResolvePlayerAttackOptions,
} from "./combatTurns";
import {
addSecretDoorFallback,
canPlaceStairsDown,
expandLevelFromExit,
getUnresolvedExits,
initializeDungeonLevel,
placeStairsDown,
} from "./dungeon";
import type { DiceRoller } from "./dice";
import { enterRoom } from "./roomEntry";
@@ -85,6 +88,12 @@ export type RunTransitionResult = {
logEntries: LogEntry[];
};
function appendDungeonFlag(run: RunState, flag: string) {
if (!run.dungeon.globalFlags.includes(flag)) {
run.dungeon.globalFlags.push(flag);
}
}
function createLogEntry(
id: string,
at: string,
@@ -275,6 +284,34 @@ function appendLogs(run: RunState, logEntries: LogEntry[]) {
run.log.push(...logEntries);
}
function ensureStalledProgressionRecovery(
run: RunState,
at: string,
): LogEntry[] {
const levelState = run.dungeon.levels[run.currentLevel];
if (!levelState || levelState.secretDoorUsed || levelState.stairsDownRoomId) {
return [];
}
if (getUnresolvedExits(levelState).length > 0 || canCompleteCurrentLevel(run)) {
return [];
}
const fallback = addSecretDoorFallback(levelState);
run.dungeon.levels[run.currentLevel] = fallback.levelState;
return [
createLogEntry(
`level.${run.currentLevel}.fallback-secret-door.${run.log.length + 1}`,
at,
"room",
`Progress stalled, so a secret exit was revealed in ${fallback.room.id}.`,
[fallback.room.id, fallback.exit.id],
),
];
}
function createRewardLog(
id: string,
at: string,
@@ -492,10 +529,12 @@ export function enterCurrentRoom(
run.dungeon.levels[run.currentLevel] = entry.levelState;
appendLogs(run, entry.logEntries);
const recoveryLogs = ensureStalledProgressionRecovery(run, options.at ?? new Date().toISOString());
appendLogs(run, recoveryLogs);
return {
run,
logEntries: entry.logEntries,
logEntries: [...entry.logEntries, ...recoveryLogs],
};
}
@@ -531,6 +570,70 @@ export function isCurrentRoomCombatReady(run: RunState) {
);
}
export function canCompleteCurrentLevel(run: RunState) {
if (run.phase !== "dungeon" || run.activeCombat || !run.currentRoomId) {
return false;
}
const levelState = run.dungeon.levels[run.currentLevel];
if (!levelState) {
return false;
}
return canPlaceStairsDown(levelState, run.currentRoomId);
}
export function completeCurrentLevel(
run: RunState,
at = new Date().toISOString(),
): RunTransitionResult {
const nextRun = cloneRun(run);
if (nextRun.phase !== "dungeon") {
throw new Error("Cannot complete a level while the run is in town.");
}
if (nextRun.activeCombat) {
throw new Error("Cannot complete a level during active combat.");
}
const levelState = requireCurrentLevel(nextRun);
const roomId = requireCurrentRoomId(nextRun);
const placement = placeStairsDown(levelState, roomId);
nextRun.dungeon.levels[nextRun.currentLevel] = placement.levelState;
nextRun.dungeon.revealedPercentByLevel[nextRun.currentLevel] = 100;
appendDungeonFlag(nextRun, `level:${nextRun.currentLevel}:completed`);
appendDungeonFlag(nextRun, `level:${nextRun.currentLevel + 1}:unlocked`);
const completionLogs = [
createLogEntry(
`level.${nextRun.currentLevel}.stairs.${nextRun.log.length + 1}`,
at,
"room",
`A stairway down was revealed in ${roomId}.`,
[roomId],
),
createLogEntry(
`level.${nextRun.currentLevel}.complete.${nextRun.log.length + 2}`,
at,
"progression",
`Completed level ${nextRun.currentLevel} and unlocked level ${nextRun.currentLevel + 1}.`,
[roomId],
),
];
appendLogs(nextRun, completionLogs);
const returned = returnToTown(nextRun, at);
return {
run: returned.run,
logEntries: [...completionLogs, ...returned.logEntries],
};
}
export function travelCurrentExit(
options: TravelCurrentExitOptions,
): RunTransitionResult {
@@ -572,17 +675,39 @@ export function travelCurrentExit(
throw new Error(`Exit ${exit.id} is no longer available for generation.`);
}
const expansion = expandLevelFromExit({
content: options.content,
levelState,
fromRoomId: roomId,
exitDirection: options.exitDirection,
roomTableCode: options.roomTableCode ?? inferNextRoomTableCode(run),
roller: options.roller,
});
try {
const expansion = expandLevelFromExit({
content: options.content,
levelState,
fromRoomId: roomId,
exitDirection: options.exitDirection,
roomTableCode: options.roomTableCode ?? inferNextRoomTableCode(run),
roller: options.roller,
});
nextLevelState = expansion.levelState;
destinationRoomId = expansion.createdRoom.id;
nextLevelState = expansion.levelState;
destinationRoomId = expansion.createdRoom.id;
} catch (error) {
exit.traversable = false;
exit.discovered = true;
run.dungeon.levels[run.currentLevel] = levelState;
const blockedLog = createLogEntry(
`${roomId}.blocked.${options.exitDirection}.${run.log.length + 1}`,
at,
"room",
`The ${options.exitDirection} passage from ${room.id} could not be extended and is now marked blocked.`,
[room.id, exit.id],
);
const recoveryLogs = ensureStalledProgressionRecovery(run, at);
appendLogs(run, [blockedLog, ...recoveryLogs]);
return {
run,
logEntries: [blockedLog, ...recoveryLogs],
};
}
}
run.dungeon.levels[run.currentLevel] = nextLevelState;
+16
View File
@@ -373,6 +373,10 @@ select {
background: rgba(255, 245, 223, 0.04);
color: #f4efe3;
padding: 0.72rem 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
cursor: pointer;
transition:
transform 140ms ease,
@@ -401,6 +405,18 @@ select {
background: linear-gradient(180deg, #d97833, #9f501b);
}
.button-file {
position: relative;
overflow: hidden;
}
.button-file input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.encounter-box,
.combat-status {
margin-top: 1rem;