Add campaign shell and map generation recovery
This commit is contained in:
+165
-51
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user