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
+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),
};
}