✨Feature: implement post-combat rewards system with XP tracking and creature defeat logging
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
||||
RunState,
|
||||
} from "@/types/state";
|
||||
import type { LogEntry } from "@/types/rules";
|
||||
import { findCreatureById } from "@/data/contentHelpers";
|
||||
|
||||
import { startCombatFromRoom } from "./combat";
|
||||
import {
|
||||
@@ -14,7 +15,11 @@ import {
|
||||
type ResolveEnemyTurnOptions,
|
||||
type ResolvePlayerAttackOptions,
|
||||
} from "./combatTurns";
|
||||
import { initializeDungeonLevel } from "./dungeon";
|
||||
import {
|
||||
expandLevelFromExit,
|
||||
getUnresolvedExits,
|
||||
initializeDungeonLevel,
|
||||
} from "./dungeon";
|
||||
import type { DiceRoller } from "./dice";
|
||||
import { enterRoom } from "./roomEntry";
|
||||
|
||||
@@ -55,6 +60,23 @@ export type ResolveRunEnemyTurnOptions = {
|
||||
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[];
|
||||
@@ -167,6 +189,37 @@ function requireCurrentRoomId(run: RunState) {
|
||||
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;
|
||||
@@ -180,6 +233,52 @@ 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,
|
||||
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;
|
||||
|
||||
if (xpAwarded === 0) {
|
||||
return [] as LogEntry[];
|
||||
}
|
||||
|
||||
return [
|
||||
createRewardLog(
|
||||
`${completedCombat.id}.rewards`,
|
||||
at,
|
||||
`Victory rewards: gained ${xpAwarded} XP from ${defeatedCreatureIds.length} defeated creature${defeatedCreatureIds.length === 1 ? "" : "s"}.`,
|
||||
[completedCombat.id, ...defeatedCreatureIds],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function createRunState(options: CreateRunOptions): RunState {
|
||||
const at = options.at ?? new Date().toISOString();
|
||||
const levelState = initializeDungeonLevel({
|
||||
@@ -204,6 +303,8 @@ export function createRunState(options: CreateRunOptions): RunState {
|
||||
globalFlags: [],
|
||||
},
|
||||
adventurerSnapshot: options.adventurer,
|
||||
defeatedCreatureIds: [],
|
||||
xpGained: 0,
|
||||
log: [],
|
||||
pendingEffects: [],
|
||||
};
|
||||
@@ -232,6 +333,106 @@ export function enterCurrentRoom(
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -285,9 +486,16 @@ export function resolveRunPlayerTurn(
|
||||
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.at ?? new Date().toISOString(),
|
||||
);
|
||||
|
||||
if (room?.encounter) {
|
||||
room.encounter.rewardPending = false;
|
||||
@@ -295,6 +503,7 @@ export function resolveRunPlayerTurn(
|
||||
}
|
||||
|
||||
run.activeCombat = undefined;
|
||||
appendLogs(run, rewardLogs);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user