From 37e2b2787098db3940e38e1cebdacaa0c7c991fb Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Wed, 18 Mar 2026 21:35:53 -0500 Subject: [PATCH] Add campaign shell and map generation recovery --- src/App.tsx | 216 ++++++++++++++++++++++++++-------- src/rules/campaign.test.ts | 102 ++++++++++++++++ src/rules/campaign.ts | 156 ++++++++++++++++++++++++ src/rules/dungeon.test.ts | 14 +++ src/rules/dungeon.ts | 162 +++++++++++++++++++++++++ src/rules/persistence.test.ts | 58 +++++++++ src/rules/persistence.ts | 143 +++++++++++++++++++++- src/rules/rooms.ts | 15 ++- src/rules/runState.test.ts | 47 ++++++++ src/rules/runState.ts | 147 +++++++++++++++++++++-- src/styles.css | 16 +++ 11 files changed, 1012 insertions(+), 64 deletions(-) create mode 100644 src/rules/campaign.test.ts create mode 100644 src/rules/campaign.ts diff --git a/src/App.tsx b/src/App.tsx index 3a8ff8f..3dcce53 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(() => createDemoRun()); - const [savedRuns, setSavedRuns] = React.useState([]); + const [session, setSession] = React.useState(() => createDemoSession()); + const [savedCampaigns, setSavedCampaigns] = React.useState([]); + 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) => { + 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() {

2D6 Dungeon Web

-

Dungeon Loop Shell

+

Campaign Command Table

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

- + +
@@ -407,6 +470,39 @@ function App() { ) : null}
+
+
+

Campaign Ledger

+ {campaign.id} +
+
+
+ Adventurer + {campaign.adventurer.name} +
+
+ Run History + {campaign.runHistory.length} +
+
+ Town Visits + {campaign.townState.visits} +
+
+ Updated + {new Date(campaign.updatedAt).toLocaleTimeString()} +
+
+
+ Completed Levels: {campaign.completedLevels.join(", ") || "None"} + Unlocked Levels: {campaign.unlockedLevels.join(", ")} +
+

+ Campaign saves include the adventurer sheet, town state, run history snapshot, and + the active dungeon delve. +

+
+

Adventurer

@@ -518,24 +614,29 @@ function App() {
-

Save Archive

- {savedRuns.length} saves +

Campaign Archive

+ {savedCampaigns.length} saves
- {savedRuns.length === 0 ? ( -

No saved runs yet. Save the current run to persist progress.

+ {savedCampaigns.length === 0 ? ( +

+ No saved campaigns yet. The current campaign autosaves as you play, and you can + archive manual snapshots here. +

) : (
- {savedRuns.map((save) => ( + {savedCampaigns.map((save) => (
{save.phase} {save.label}

Saved {new Date(save.savedAt).toLocaleString()} · Level {save.currentLevel} + {" · "} + Town visits {save.visits}

- +
Encounter {currentRoom?.encounter?.resultLabel ?? "None"}
+ {levelCompletionReady ? ( +

+ This room qualifies as the final cleared chamber. Completing the level will reveal + stairs down, record the victory, and return you to town. +

+ ) : null}
diff --git a/src/rules/campaign.test.ts b/src/rules/campaign.test.ts new file mode 100644 index 0000000..c1bf47c --- /dev/null +++ b/src/rules/campaign.test.ts @@ -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"); + }); +}); diff --git a/src/rules/campaign.ts b/src/rules/campaign.ts new file mode 100644 index 0000000..33dd534 --- /dev/null +++ b/src/rules/campaign.ts @@ -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), + }; +} diff --git a/src/rules/dungeon.test.ts b/src/rules/dungeon.test.ts index 78cc0b8..778b572 100644 --- a/src/rules/dungeon.test.ts +++ b/src/rules/dungeon.test.ts @@ -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"); + }); }); diff --git a/src/rules/dungeon.ts b/src/rules/dungeon.ts index adb7b41..bb4ff20 100644 --- a/src/rules/dungeon.ts +++ b/src/rules/dungeon.ts @@ -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 = { 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."); +} diff --git a/src/rules/persistence.test.ts b/src/rules/persistence.test.ts index ccabc54..2aeee48 100644 --- a/src/rules/persistence.test.ts +++ b/src/rules/persistence.test.ts @@ -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); + }); +}); diff --git a/src/rules/persistence.ts b/src/rules/persistence.ts index 13b2c11..39d0d4f 100644 --- a/src/rules/persistence.ts +++ b/src/rules/persistence.ts @@ -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; +} diff --git a/src/rules/rooms.ts b/src/rules/rooms.ts index 7b0228c..d0d4640 100644 --- a/src/rules/rooms.ts +++ b/src/rules/rooms.ts @@ -40,6 +40,18 @@ const DEFAULT_ROOM_DIMENSIONS: Record 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, diff --git a/src/rules/runState.test.ts b/src/rules/runState.test.ts index b7231c2..095a682 100644 --- a/src/rules/runState.test.ts +++ b/src/rules/runState.test.ts @@ -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"); + }); }); diff --git a/src/rules/runState.ts b/src/rules/runState.ts index 5eed5ca..07aa9fd 100644 --- a/src/rules/runState.ts +++ b/src/rules/runState.ts @@ -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; diff --git a/src/styles.css b/src/styles.css index 750a173..c945db3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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;