Add campaign shell and map generation recovery
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user