581 lines
15 KiB
TypeScript
581 lines
15 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 {
|
|
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,
|
|
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,
|
|
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,
|
|
};
|
|
}
|