Files
2D6-Dungeon/src/rules/runState.ts

590 lines
16 KiB
TypeScript

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