import type { ContentPack } from "@/types/content"; import type { AdventurerState, CombatState, DungeonState, RunState, } from "@/types/state"; import type { LogEntry } from "@/types/rules"; import { startCombatFromRoom } from "./combat"; import { resolveEnemyTurn, resolvePlayerAttack, type ResolveEnemyTurnOptions, type ResolvePlayerAttackOptions, } from "./combatTurns"; import { 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 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, 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 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); } 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, 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 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 levelState = requireCurrentLevel(run); const roomId = requireCurrentRoomId(run); const room = levelState.rooms[roomId]; if (room?.encounter) { room.encounter.rewardPending = false; room.discovery.cleared = true; } run.activeCombat = undefined; } 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, }; }