import type { ContentPack } from "@/types/content"; import type { AdventurerState, CombatState, DungeonState, RunState, } from "@/types/state"; import type { LogEntry } from "@/types/rules"; import { findCreatureById } from "@/data/contentHelpers"; import { startCombatFromRoom } from "./combat"; import { resolveCombatLoot } from "./loot"; import { createInitialTownState } from "./town"; import { resolveEnemyTurn, resolvePlayerAttack, type ResolveEnemyTurnOptions, type ResolvePlayerAttackOptions, } from "./combatTurns"; import { expandLevelFromExit, getUnresolvedExits, initializeDungeonLevel, } from "./dungeon"; import type { DiceRoller } from "./dice"; import { enterRoom } from "./roomEntry"; export type CreateRunOptions = { content: ContentPack; campaignId: string; adventurer: AdventurerState; runId?: string; at?: string; }; export type EnterCurrentRoomOptions = { content: ContentPack; run: RunState; roller?: DiceRoller; at?: string; }; export type StartCurrentCombatOptions = { content: ContentPack; run: RunState; at?: string; }; export type ResolveRunPlayerTurnOptions = { content: ContentPack; run: RunState; manoeuvreId: string; targetEnemyId: string; roller?: DiceRoller; at?: string; }; export type ResolveRunEnemyTurnOptions = { content: ContentPack; run: RunState; roller?: DiceRoller; at?: string; }; export type TravelCurrentExitOptions = { content: ContentPack; run: RunState; exitDirection: "north" | "east" | "south" | "west"; roomTableCode?: string; roller?: DiceRoller; at?: string; }; export type AvailableMove = { direction: "north" | "east" | "south" | "west"; exitType: string; discovered: boolean; leadsToRoomId?: string; generated: boolean; }; export type RunTransitionResult = { run: RunState; logEntries: LogEntry[]; }; function cloneCombat(combat: CombatState): CombatState { return { ...combat, player: { ...combat.player, statuses: [...combat.player.statuses], traits: [...combat.player.traits], }, enemies: combat.enemies.map((enemy) => ({ ...enemy, statuses: [...enemy.statuses], traits: [...enemy.traits], })), combatLog: combat.combatLog.map((entry) => ({ ...entry, relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined, })), }; } function cloneRun(run: RunState): RunState { return { ...run, adventurerSnapshot: { ...run.adventurerSnapshot, hp: { ...run.adventurerSnapshot.hp }, stats: { ...run.adventurerSnapshot.stats }, favour: { ...run.adventurerSnapshot.favour }, statuses: run.adventurerSnapshot.statuses.map((status) => ({ ...status })), inventory: { ...run.adventurerSnapshot.inventory, carried: run.adventurerSnapshot.inventory.carried.map((entry) => ({ ...entry })), equipped: run.adventurerSnapshot.inventory.equipped.map((entry) => ({ ...entry })), stored: run.adventurerSnapshot.inventory.stored.map((entry) => ({ ...entry })), currency: { ...run.adventurerSnapshot.inventory.currency }, lightSources: run.adventurerSnapshot.inventory.lightSources.map((entry) => ({ ...entry })), }, progressionFlags: [...run.adventurerSnapshot.progressionFlags], manoeuvreIds: [...run.adventurerSnapshot.manoeuvreIds], }, dungeon: { levels: Object.fromEntries( Object.entries(run.dungeon.levels).map(([level, levelState]) => [ level, { ...levelState, rooms: Object.fromEntries( Object.entries(levelState.rooms).map(([roomId, room]) => [ roomId, { ...room, position: { ...room.position }, dimensions: { ...room.dimensions }, exits: room.exits.map((exit) => ({ ...exit })), discovery: { ...room.discovery }, encounter: room.encounter ? { ...room.encounter, creatureIds: [...room.encounter.creatureIds], creatureNames: room.encounter.creatureNames ? [...room.encounter.creatureNames] : undefined, } : undefined, objects: room.objects.map((object) => ({ ...object, effects: object.effects ? [...object.effects] : undefined, })), notes: [...room.notes], flags: [...room.flags], }, ]), ), discoveredRoomOrder: [...levelState.discoveredRoomOrder], }, ]), ) as DungeonState["levels"], revealedPercentByLevel: { ...run.dungeon.revealedPercentByLevel }, globalFlags: [...run.dungeon.globalFlags], }, activeCombat: run.activeCombat ? cloneCombat(run.activeCombat) : undefined, townState: { ...run.townState, knownServices: [...run.townState.knownServices], stash: run.townState.stash.map((entry) => ({ ...entry })), pendingSales: run.townState.pendingSales.map((entry) => ({ ...entry })), serviceFlags: [...run.townState.serviceFlags], }, defeatedCreatureIds: [...run.defeatedCreatureIds], xpGained: run.xpGained, goldGained: run.goldGained, lootedItems: run.lootedItems.map((entry) => ({ ...entry })), log: run.log.map((entry) => ({ ...entry, relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined, })), pendingEffects: run.pendingEffects.map((effect) => ({ ...effect })), }; } function requireCurrentLevel(run: RunState) { const levelState = run.dungeon.levels[run.currentLevel]; if (!levelState) { throw new Error(`Run is missing level ${run.currentLevel}.`); } return levelState; } function requireCurrentRoomId(run: RunState) { if (!run.currentRoomId) { throw new Error("Run does not have a current room."); } return run.currentRoomId; } function requireCurrentRoom(run: RunState) { const levelState = requireCurrentLevel(run); const roomId = requireCurrentRoomId(run); const room = levelState.rooms[roomId]; if (!room) { throw new Error(`Unknown room id: ${roomId}`); } return room; } function inferNextRoomTableCode(run: RunState) { const room = requireCurrentRoom(run); const levelState = requireCurrentLevel(run); if (room.roomClass === "start") { return "L1LR"; } if (room.roomClass === "small") { return "L1LR"; } if (room.roomClass === "large") { return "L1SR"; } return levelState.discoveredRoomOrder.length % 2 === 0 ? "L1LR" : "L1SR"; } function syncPlayerToAdventurer(run: RunState) { if (!run.activeCombat) { return; } run.adventurerSnapshot.hp.current = run.activeCombat.player.hpCurrent; run.adventurerSnapshot.statuses = run.activeCombat.player.statuses.map((status) => ({ ...status })); } function appendLogs(run: RunState, logEntries: LogEntry[]) { run.log.push(...logEntries); } function createRewardLog( id: string, at: string, text: string, relatedIds: string[], ): LogEntry { return { id, at, type: "progression", text, relatedIds, }; } function applyCombatRewards( content: ContentPack, run: RunState, completedCombat: CombatState, roller: DiceRoller | undefined, at: string, ) { const defeatedCreatureIds = completedCombat.enemies .filter((enemy) => enemy.hpCurrent === 0 && enemy.sourceDefinitionId) .map((enemy) => enemy.sourceDefinitionId!); const xpAwarded = defeatedCreatureIds.reduce((total, creatureId) => { return total + (findCreatureById(content, creatureId).xpReward ?? 0); }, 0); run.defeatedCreatureIds.push(...defeatedCreatureIds); run.xpGained += xpAwarded; run.adventurerSnapshot.xp += xpAwarded; const lootResult = resolveCombatLoot({ content, combat: completedCombat, inventory: run.adventurerSnapshot.inventory, roller, at, }); run.adventurerSnapshot.inventory = lootResult.inventory; run.goldGained += lootResult.goldAwarded; for (const item of lootResult.itemsAwarded) { const existing = run.lootedItems.find( (entry) => entry.definitionId === item.definitionId, ); if (existing) { existing.quantity += item.quantity; continue; } run.lootedItems.push({ ...item }); } const rewardLogs = [...lootResult.logEntries]; if (xpAwarded === 0 && lootResult.goldAwarded === 0 && lootResult.itemsAwarded.length === 0) { return rewardLogs; } rewardLogs.push( createRewardLog( `${completedCombat.id}.rewards`, at, `Victory rewards: gained ${xpAwarded} XP, ${lootResult.goldAwarded} gold, and ${lootResult.itemsAwarded.reduce((total, item) => total + item.quantity, 0)} loot item${lootResult.itemsAwarded.reduce((total, item) => total + item.quantity, 0) === 1 ? "" : "s"} from ${defeatedCreatureIds.length} defeated creature${defeatedCreatureIds.length === 1 ? "" : "s"}.`, [completedCombat.id, ...defeatedCreatureIds], ), ); return rewardLogs; } export function createRunState(options: CreateRunOptions): RunState { const at = options.at ?? new Date().toISOString(); const levelState = initializeDungeonLevel({ content: options.content, level: 1, }); return { id: options.runId ?? "run.active", campaignId: options.campaignId, status: "active", startedAt: at, currentLevel: 1, currentRoomId: "room.level1.start", dungeon: { levels: { 1: levelState, }, revealedPercentByLevel: { 1: 0, }, globalFlags: [], }, adventurerSnapshot: options.adventurer, townState: createInitialTownState(), defeatedCreatureIds: [], xpGained: 0, goldGained: 0, lootedItems: [], log: [], pendingEffects: [], }; } export function enterCurrentRoom( options: EnterCurrentRoomOptions, ): RunTransitionResult { const run = cloneRun(options.run); const levelState = requireCurrentLevel(run); const roomId = requireCurrentRoomId(run); const entry = enterRoom({ content: options.content, levelState, roomId, roller: options.roller, at: options.at, }); run.dungeon.levels[run.currentLevel] = entry.levelState; appendLogs(run, entry.logEntries); return { run, logEntries: entry.logEntries, }; } export function getAvailableMoves(run: RunState): AvailableMove[] { const room = requireCurrentRoom(run); return room.exits .filter((exit) => exit.traversable) .map((exit) => ({ direction: exit.direction, exitType: exit.exitType, discovered: exit.discovered, leadsToRoomId: exit.leadsToRoomId, generated: Boolean(exit.leadsToRoomId), })); } export function isCurrentRoomCombatReady(run: RunState) { const room = requireCurrentRoom(run); return Boolean( room.encounter?.resolved && room.encounter.creatureNames && room.encounter.creatureNames.length > 0, ); } export function travelCurrentExit( options: TravelCurrentExitOptions, ): RunTransitionResult { const run = cloneRun(options.run); if (run.activeCombat) { throw new Error("Cannot travel while combat is active."); } const levelState = requireCurrentLevel(run); const roomId = requireCurrentRoomId(run); const room = requireCurrentRoom(run); const exit = room.exits.find((candidate) => candidate.direction === options.exitDirection); if (!exit) { throw new Error(`Current room does not have an exit to the ${options.exitDirection}.`); } if (!exit.traversable) { throw new Error(`Exit ${exit.id} is not traversable.`); } let nextLevelState = levelState; let destinationRoomId = exit.leadsToRoomId; const at = options.at ?? new Date().toISOString(); if (!destinationRoomId) { const unresolvedExits = getUnresolvedExits(levelState); const matchingExit = unresolvedExits.find( (candidate) => candidate.roomId === roomId && candidate.direction === options.exitDirection, ); if (!matchingExit) { 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, }); nextLevelState = expansion.levelState; destinationRoomId = expansion.createdRoom.id; } run.dungeon.levels[run.currentLevel] = nextLevelState; run.currentRoomId = destinationRoomId; const movedLog: LogEntry = { id: `${roomId}.travel.${options.exitDirection}.${run.log.length + 1}`, at, type: "room", text: `Travelled ${options.exitDirection} from ${room.id} to ${destinationRoomId}.`, relatedIds: [room.id, destinationRoomId], }; appendLogs(run, [movedLog]); const entered = enterCurrentRoom({ content: options.content, run, roller: options.roller, at, }); return { run: entered.run, logEntries: [movedLog, ...entered.logEntries], }; } export function startCombatInCurrentRoom( options: StartCurrentCombatOptions, ): RunTransitionResult { const run = cloneRun(options.run); const levelState = requireCurrentLevel(run); const roomId = requireCurrentRoomId(run); const room = levelState.rooms[roomId]; if (!room) { throw new Error(`Unknown room id: ${roomId}`); } const started = startCombatFromRoom({ content: options.content, adventurer: run.adventurerSnapshot, room, at: options.at, }); levelState.rooms[roomId] = started.room; run.activeCombat = started.combat; appendLogs(run, started.logEntries); return { run, logEntries: started.logEntries, }; } export function resolveRunPlayerTurn( options: ResolveRunPlayerTurnOptions, ): RunTransitionResult { const run = cloneRun(options.run); if (!run.activeCombat) { throw new Error("Run does not have an active combat."); } const result = resolvePlayerAttack({ content: options.content, combat: run.activeCombat, adventurer: run.adventurerSnapshot, manoeuvreId: options.manoeuvreId, targetEnemyId: options.targetEnemyId, roller: options.roller, at: options.at, } satisfies ResolvePlayerAttackOptions); run.activeCombat = result.combat; syncPlayerToAdventurer(run); appendLogs(run, result.logEntries); if (result.combatEnded) { const completedCombat = result.combat; const levelState = requireCurrentLevel(run); const roomId = requireCurrentRoomId(run); const room = levelState.rooms[roomId]; const rewardLogs = applyCombatRewards( options.content, run, completedCombat, options.roller, options.at ?? new Date().toISOString(), ); if (room?.encounter) { room.encounter.rewardPending = false; room.discovery.cleared = true; } run.activeCombat = undefined; appendLogs(run, rewardLogs); } return { run, logEntries: result.logEntries, }; } export function resolveRunEnemyTurn( options: ResolveRunEnemyTurnOptions, ): RunTransitionResult { const run = cloneRun(options.run); if (!run.activeCombat) { throw new Error("Run does not have an active combat."); } const result = resolveEnemyTurn({ content: options.content, combat: run.activeCombat, adventurer: run.adventurerSnapshot, roller: options.roller, at: options.at, } satisfies ResolveEnemyTurnOptions); run.activeCombat = result.combat; syncPlayerToAdventurer(run); appendLogs(run, result.logEntries); if (result.combatEnded) { run.status = "failed"; } return { run, logEntries: result.logEntries, }; }