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