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