Expand room generation fidelity and magic item actions

This commit is contained in:
Keith Solomon
2026-03-20 17:06:45 -05:00
parent 37e2b27870
commit 9f494461de
19 changed files with 3800 additions and 6 deletions
+1
View File
@@ -123,6 +123,7 @@ export function createStartingAdventurer(
stored: [],
currency: {
gold: 0,
silver: 0,
},
rationCount: 3,
lightSources: [makeInventoryEntry("item.lantern")],
+37 -2
View File
@@ -7,6 +7,12 @@ import type {
import type { AdventurerState, CombatState, CombatantState } from "@/types/state";
import type { LogEntry } from "@/types/rules";
import {
INSIGHTFUL_COMBAT_STATUS_ID,
SLEEPING_STATUS_ID,
consumeWardReduction,
consumeStatusValue,
} from "./magicItems";
import { roll2D6, type DiceRoller } from "./dice";
export type ResolvePlayerAttackOptions = {
@@ -138,9 +144,11 @@ export function resolvePlayerAttack(
}
const roll = roll2D6(options.roller);
const insightfulBonus = consumeStatusValue(combat.player.statuses, INSIGHTFUL_COMBAT_STATUS_ID);
const accuracy =
(roll.total ?? 0) +
combat.player.precision +
insightfulBonus +
(manoeuvre.precisionModifier ?? 0);
const targetNumber = BASE_TARGET_NUMBER + (target.armourValue ?? 0);
const hit = accuracy >= targetNumber;
@@ -207,15 +215,42 @@ export function resolveEnemyTurn(
throw new Error("No living enemies are available to act.");
}
const sleptThroughTurn = consumeStatusValue(attacker.statuses, SLEEPING_STATUS_ID) > 0;
if (sleptThroughTurn) {
combat.actingSide = "player";
combat.round += 1;
const logEntries: LogEntry[] = [
createLogEntry(
`${combat.id}.enemy.${combat.combatLog.length + 1}`,
at,
`${attacker.name} sleeps through the turn.`,
[attacker.id, combat.player.id],
),
];
combat.combatLog.push(...logEntries);
return {
combat,
logEntries,
defeatedEnemyIds: [],
combatEnded: false,
};
}
const roll = roll2D6(options.roller);
const armourValue = getPlayerArmourValue(options.content, options.adventurer);
const accuracy = (roll.total ?? 0) + attacker.precision;
const targetNumber = BASE_TARGET_NUMBER + armourValue;
const hit = accuracy >= targetNumber;
const rawDamage = hit ? Math.max(1, 1 + attacker.discipline) : 0;
const damageReduction = hit ? consumeWardReduction(combat.player.statuses) : 0;
const damage = hit ? Math.max(0, rawDamage - damageReduction) : 0;
if (hit) {
combat.player.hpCurrent = Math.max(0, combat.player.hpCurrent - rawDamage);
combat.player.hpCurrent = Math.max(0, combat.player.hpCurrent - damage);
}
combat.lastRoll = roll;
@@ -227,7 +262,7 @@ export function resolveEnemyTurn(
`${combat.id}.enemy.${combat.combatLog.length + 1}`,
at,
hit
? `${attacker.name} attacks ${combat.player.name}, rolls ${roll.total}, and deals ${rawDamage} damage.`
? `${attacker.name} attacks ${combat.player.name}, rolls ${roll.total}, and deals ${damage} damage${damageReduction > 0 ? ` after resistance reduces it by ${damageReduction}` : ""}.`
: `${attacker.name} attacks ${combat.player.name}, rolls ${roll.total}, and misses.`,
[attacker.id, combat.player.id],
),
+71
View File
@@ -0,0 +1,71 @@
import type { RoomState, RunState, StatusInstance } from "@/types/state";
export const AMULET_RESISTANCE_STATUS_ID = "status.amulet-of-resistance";
export const AMULET_FIRE_RESISTANCE_STATUS_ID = "status.amulet-of-fire-resistance";
export const INSIGHTFUL_COMBAT_STATUS_ID = "status.insightful-combat";
export const SLEEPING_STATUS_ID = "status.sleeping";
function findCarriedEntry(run: RunState, definitionId: string) {
return run.adventurerSnapshot.inventory.carried.find((entry) => entry.definitionId === definitionId);
}
export function getCarriedItemCount(run: RunState, definitionId: string) {
return findCarriedEntry(run, definitionId)?.quantity ?? 0;
}
export function consumeCarriedItem(run: RunState, definitionId: string, quantity = 1) {
const existing = findCarriedEntry(run, definitionId);
if (!existing || existing.quantity < quantity) {
throw new Error(`No carried ${definitionId} is available to consume.`);
}
existing.quantity -= quantity;
if (existing.quantity === 0) {
const index = run.adventurerSnapshot.inventory.carried.indexOf(existing);
run.adventurerSnapshot.inventory.carried.splice(index, 1);
}
}
export function hasStatus(statuses: StatusInstance[], statusId: string) {
return statuses.some((status) => status.id === statusId);
}
export function addStatus(statuses: StatusInstance[], status: StatusInstance) {
if (!hasStatus(statuses, status.id)) {
statuses.push(status);
}
}
export function consumeStatusValue(statuses: StatusInstance[], statusId: string) {
const index = statuses.findIndex((status) => status.id === statusId);
if (index === -1) {
return 0;
}
const [removed] = statuses.splice(index, 1);
return removed?.value ?? 0;
}
export function consumeWardReduction(statuses: StatusInstance[]) {
return (
consumeStatusValue(statuses, AMULET_RESISTANCE_STATUS_ID) +
consumeStatusValue(statuses, AMULET_FIRE_RESISTANCE_STATUS_ID)
);
}
export function revealHiddenObjects(room: RoomState) {
const hiddenObjects = room.objects.filter((object) => object.hidden);
hiddenObjects.forEach((object) => {
object.hidden = false;
});
if (hiddenObjects.length > 0) {
room.discovery.searched = true;
}
return hiddenObjects;
}
+319
View File
@@ -0,0 +1,319 @@
import { describe, expect, it } from "vitest";
import { sampleContentPack } from "@/data/sampleContentPack";
import { createStartingAdventurer } from "./character";
import { createRoomStateFromTemplate } from "./rooms";
import { resolveRoomObject, searchRoom } from "./roomObjects";
import { createRunState } from "./runState";
function createAdventurer() {
return createStartingAdventurer(sampleContentPack, {
name: "Aster",
weaponId: "weapon.short-sword",
armourId: "armour.leather-vest",
scrollId: "scroll.lesser-heal",
});
}
function createSequenceRoller(values: number[]) {
let index = 0;
return () => {
const next = values[index] ?? values.at(-1) ?? 1;
index += 1;
return next;
};
}
describe("room objects", () => {
it("seeds room objects from searchable room templates", () => {
const room = createRoomStateFromTemplate(
sampleContentPack,
"room.level1.test",
1,
"room.level1.normal.abandoned-guard-post",
);
expect(room.objects.length).toBeGreaterThan(0);
expect(room.objects.some((object) => object.objectType === "container")).toBe(true);
expect(room.objects[0]?.sourceTableCode).toBe("PT1");
});
it("reveals hidden objects when the room is searched", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
const room = createRoomStateFromTemplate(
sampleContentPack,
"room.level1.test",
1,
"room.level1.large.dormitory",
);
const hiddenObject = room.objects.find((object) => object.hidden);
expect(hiddenObject).toBeDefined();
const result = searchRoom(run, room, "2026-03-18T21:00:00.000Z");
expect(result.room.discovery.searched).toBe(true);
expect(result.room.objects.every((object) => object.hidden !== true)).toBe(true);
expect(result.logEntries[0]?.text).toContain("reveals");
});
it("supports multiple codex-aligned objects in a single room", () => {
const room = createRoomStateFromTemplate(
sampleContentPack,
"room.level1.test",
1,
"room.level1.large.crate-store",
);
expect(room.objects).toEqual(
expect.arrayContaining([
expect.objectContaining({ sourceTableCode: "TCT1" }),
expect.objectContaining({ sourceTableCode: "SECT1", hidden: true }),
]),
);
});
it("assigns codex magical interaction tables to more Level 1 rooms", () => {
const temple = createRoomStateFromTemplate(
sampleContentPack,
"room.level1.test",
1,
"room.level1.large.temple",
);
const library = createRoomStateFromTemplate(
sampleContentPack,
"room.level1.test.library",
1,
"room.level1.large.library",
);
expect(temple.objects).toEqual(
expect.arrayContaining([expect.objectContaining({ sourceTableCode: "MA1" })]),
);
expect(library.objects).toEqual(
expect.arrayContaining([expect.objectContaining({ sourceTableCode: "SCT1" })]),
);
});
it("resolves chained codex follow-up tables into actual carried loot", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
const room = createRoomStateFromTemplate(
sampleContentPack,
"room.level1.test",
1,
"room.level1.normal.guard-post",
);
room.objects.forEach((object) => {
object.hidden = false;
});
const chest = room.objects.find((object) => object.sourceTableCode === "CT1");
expect(chest).toBeDefined();
const result = resolveRoomObject({
content: sampleContentPack,
run,
room,
objectId: chest!.id,
at: "2026-03-18T21:02:00.000Z",
roller: createSequenceRoller([4, 6, 1, 1]),
});
expect(result.logEntries.some((entry) => entry.text.includes("Follow-up roll"))).toBe(true);
expect(run.adventurerSnapshot.inventory.carried).toEqual(
expect.arrayContaining([
expect.objectContaining({ definitionId: "scroll.balance" }),
expect.objectContaining({ definitionId: "item.half-copper-pendant" }),
]),
);
});
it("repeats follow-up table rolls when the codex entry calls for multiples", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
const room = createRoomStateFromTemplate(
sampleContentPack,
"room.level1.test",
1,
"room.level1.normal.mourning-quarters",
);
room.objects.forEach((object) => {
object.hidden = false;
});
const corpse = room.objects.find((object) => object.sourceTableCode === "BST2");
expect(corpse).toBeDefined();
const result = resolveRoomObject({
content: sampleContentPack,
run,
room,
objectId: corpse!.id,
at: "2026-03-19T18:05:00.000Z",
roller: createSequenceRoller([4, 6, 1, 2, 3]),
});
expect(result.logEntries.filter((entry) => entry.text.includes("Follow-up roll")).length).toBe(3);
expect(run.adventurerSnapshot.inventory.carried).toEqual(
expect.arrayContaining([
expect.objectContaining({ definitionId: "item.pearl" }),
expect.objectContaining({ definitionId: "item.sapphire" }),
expect.objectContaining({ definitionId: "item.garnet" }),
]),
);
});
it("awards fixed silver outcomes from codex entries", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
const room = createRoomStateFromTemplate(
sampleContentPack,
"room.level1.test",
1,
"room.level1.large.dormitory",
);
room.objects.forEach((object) => {
object.hidden = false;
});
const pouch = room.objects.find((object) => object.sourceTableCode === "PT2");
expect(pouch).toBeDefined();
resolveRoomObject({
content: sampleContentPack,
run,
room,
objectId: pouch!.id,
at: "2026-03-19T18:07:00.000Z",
roller: createSequenceRoller([2, 3]),
});
expect(run.adventurerSnapshot.inventory.currency.silver).toBe(25);
expect(run.silverGained).toBe(25);
});
it("resolves dice-based silver and gold rewards from codex entries", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
const room = createRoomStateFromTemplate(
sampleContentPack,
"room.level1.test",
1,
"room.level1.normal.abandoned-guard-post",
);
room.objects.forEach((object) => {
object.hidden = false;
});
const pouch = room.objects.find((object) => object.sourceTableCode === "PT1");
expect(pouch).toBeDefined();
resolveRoomObject({
content: sampleContentPack,
run,
room,
objectId: pouch!.id,
at: "2026-03-19T18:09:00.000Z",
roller: createSequenceRoller([4, 5, 6, 2]),
});
expect(run.adventurerSnapshot.inventory.currency.silver).toBe(31);
expect(run.adventurerSnapshot.inventory.currency.gold).toBe(2);
});
it("resolves PT2 random gem results through an explicit d3 follow-up table", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
const room = createRoomStateFromTemplate(
sampleContentPack,
"room.level1.test",
1,
"room.level1.large.dormitory",
);
room.objects.forEach((object) => {
object.hidden = false;
});
const pouch = room.objects.find((object) => object.sourceTableCode === "PT2");
expect(pouch).toBeDefined();
resolveRoomObject({
content: sampleContentPack,
run,
room,
objectId: pouch!.id,
at: "2026-03-19T18:12:00.000Z",
roller: createSequenceRoller([4, 6, 3]),
});
expect(run.adventurerSnapshot.inventory.currency.gold).toBe(20);
expect(run.adventurerSnapshot.inventory.carried).toEqual(
expect.arrayContaining([expect.objectContaining({ definitionId: "item.garnet" })]),
);
});
it("resolves a room object into loot or damage", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
const room = createRoomStateFromTemplate(
sampleContentPack,
"room.level1.test",
1,
"room.level1.large.crate-store",
);
room.objects.forEach((object) => {
object.hidden = false;
});
const container = room.objects.find((object) => object.objectType === "container");
expect(container).toBeDefined();
const result = resolveRoomObject({
content: sampleContentPack,
run,
room,
objectId: container!.id,
at: "2026-03-18T21:01:00.000Z",
roller: () => 6,
});
expect(result.object.interacted).toBe(true);
expect(result.object.resolutionLabel).toBe("Garnet Ring and coins");
expect(run.adventurerSnapshot.inventory.carried).toEqual(
expect.arrayContaining([expect.objectContaining({ definitionId: "item.garnet-ring" })]),
);
expect(result.logEntries[0]?.text).toContain("Rolled");
expect(result.logEntries[1]?.text).toContain("Garnet Ring and coins");
});
});
+535
View File
@@ -0,0 +1,535 @@
import { findTableByCode } from "@/data/contentHelpers";
import type { ContentPack, RoomObjectTemplate, RoomTemplate } from "@/types/content";
import type { InventoryEntry, RoomObjectState, RoomState, RunState } from "@/types/state";
import type { ContentReference, LogEntry, RuleEffect } from "@/types/rules";
import type { DiceRoller } from "./dice";
import { rollDice } from "./dice";
import { consumeWardReduction } from "./magicItems";
import { lookupTable } from "./tables";
export type SearchRoomResult = {
run: RunState;
room: RoomState;
logEntries: LogEntry[];
};
export type ResolveRoomObjectOptions = {
content: ContentPack;
run: RunState;
room: RoomState;
objectId: string;
at?: string;
roller?: DiceRoller;
};
export type ResolveRoomObjectResult = {
run: RunState;
room: RoomState;
object: RoomObjectState;
logEntries: LogEntry[];
};
function createLogEntry(
id: string,
at: string,
type: LogEntry["type"],
text: string,
relatedIds?: string[],
): LogEntry {
return {
id,
at,
type,
text,
relatedIds,
};
}
function getTemplateText(template: RoomTemplate) {
return `${template.title} ${template.text ?? ""} ${template.encounterText ?? ""}`.toLowerCase();
}
function createObjectState(
templateId: string,
index: number,
object: RoomObjectTemplate,
): RoomObjectState {
return {
id: `${templateId}.object.${index + 1}`,
objectType: object.objectType,
title: object.title,
sourceTableCode: object.sourceTableCode,
interacted: false,
resolved: false,
hidden: object.hidden ?? false,
searchable:
object.searchable ?? (object.objectType === "container" || object.objectType === "corpse"),
notes: object.notes,
};
}
function createHeuristicObjects(template: RoomTemplate): RoomObjectState[] {
const text = getTemplateText(template);
const objects: RoomObjectState[] = [];
const pushObject = (
objectType: RoomObjectState["objectType"],
title: string,
options?: Partial<RoomObjectState>,
) => {
objects.push({
id: `${template.id}.object.${objects.length + 1}`,
objectType,
title,
interacted: false,
searchable: objectType === "container" || objectType === "corpse",
hidden: objectType === "container" && template.tags.includes("search"),
resolved: false,
...options,
});
};
if (text.includes("chest") || text.includes("crate") || text.includes("search")) {
pushObject("container", "Searchable Cache", {
sourceTableCode: "CT1",
});
}
if (text.includes("corpse") || text.includes("body")) {
pushObject("corpse", "Fallen Body", {
sourceTableCode: "BST1",
});
}
if (template.tags.includes("hazard")) {
pushObject("hazard", "Room Hazard", {
hidden: false,
searchable: false,
sourceTableCode: "L1TR",
notes: "This danger triggers when you meddle with the room.",
});
}
if (text.includes("altar")) {
pushObject("altar", "Strange Altar", {
hidden: false,
searchable: false,
sourceTableCode: "URL1",
notes: "The altar seems important and can be inspected.",
});
}
if (text.includes("prisoner")) {
pushObject("quest", "Possible Prisoner", {
hidden: true,
searchable: true,
sourceTableCode: "ENP1",
notes: "Searching may uncover a captive or hidden stash.",
});
}
return objects;
}
export function createRoomObjectsFromTemplate(template: RoomTemplate): RoomObjectState[] {
if (template.objects?.length) {
return template.objects.map((object, index) => createObjectState(template.id, index, object));
}
return createHeuristicObjects(template);
}
function awardEntry(run: RunState, definitionId: string, quantity = 1) {
const existing = run.adventurerSnapshot.inventory.carried.find(
(entry) => entry.definitionId === definitionId,
);
if (existing) {
existing.quantity += quantity;
return;
}
run.adventurerSnapshot.inventory.carried.push({
definitionId,
quantity,
} satisfies InventoryEntry);
}
function isAwardableReferenceType(referenceType: ContentReference["type"]) {
return ["item", "potion", "scroll", "armour", "weapon"].includes(referenceType);
}
function applyRuleEffect(
run: RunState,
effect: RuleEffect,
roller?: DiceRoller,
): { gold: number; silver: number; items: number; damage: number; healing: number } {
const rolledAmount =
effect.diceKind
? Array.from({ length: effect.rollCount ?? 1 }, () => {
const roll = rollDice(effect.diceKind!, roller);
return roll.modifiedTotal ?? roll.total ?? 0;
}).reduce((total, value) => total + value, 0)
: 0;
const amount = (effect.amount ?? 0) + rolledAmount;
switch (effect.type) {
case "gain-gold": {
run.adventurerSnapshot.inventory.currency.gold += amount;
run.goldGained += amount;
return { gold: amount, silver: 0, items: 0, damage: 0, healing: 0 };
}
case "gain-silver": {
run.adventurerSnapshot.inventory.currency.silver += amount;
run.silverGained += amount;
return { gold: 0, silver: amount, items: 0, damage: 0, healing: 0 };
}
case "take-damage": {
const prevented = consumeWardReduction(run.adventurerSnapshot.statuses);
run.adventurerSnapshot.hp.current = Math.max(0, run.adventurerSnapshot.hp.current - amount);
if (prevented > 0) {
run.adventurerSnapshot.hp.current = Math.min(
run.adventurerSnapshot.hp.max,
run.adventurerSnapshot.hp.current + Math.min(prevented, amount),
);
}
if (run.activeCombat) {
run.activeCombat.player.hpCurrent = run.adventurerSnapshot.hp.current;
consumeWardReduction(run.activeCombat.player.statuses);
}
return {
gold: 0,
silver: 0,
items: 0,
damage: Math.max(0, amount - prevented),
healing: 0,
};
}
case "heal": {
const current = run.adventurerSnapshot.hp.current;
const max = run.adventurerSnapshot.hp.max;
const healed = Math.max(0, Math.min(amount, max - current));
run.adventurerSnapshot.hp.current += healed;
if (run.activeCombat) {
run.activeCombat.player.hpCurrent = run.adventurerSnapshot.hp.current;
}
return { gold: 0, silver: 0, items: 0, damage: 0, healing: healed };
}
case "add-item": {
if (!effect.referenceId) {
return { gold: 0, silver: 0, items: 0, damage: 0, healing: 0 };
}
const quantity = effect.amount ?? 1;
awardEntry(run, effect.referenceId, quantity);
run.lootedItems.push({
definitionId: effect.referenceId,
quantity,
});
return { gold: 0, silver: 0, items: quantity, damage: 0, healing: 0 };
}
default:
return { gold: 0, silver: 0, items: 0, damage: 0, healing: 0 };
}
}
function summarizeOutcome(summary: {
gold: number;
silver: number;
items: number;
damage: number;
healing: number;
}) {
const parts: string[] = [];
if (summary.gold > 0) {
parts.push(`${summary.gold} gold`);
}
if (summary.silver > 0) {
parts.push(`${summary.silver} silver`);
}
if (summary.items > 0) {
parts.push(summary.items === 1 ? "1 item" : `${summary.items} items`);
}
if (summary.damage > 0) {
parts.push(`${summary.damage} damage`);
}
if (summary.healing > 0) {
parts.push(`${summary.healing} HP`);
}
return parts.join(", ");
}
function findTableByReference(content: ContentPack, referenceId: string) {
const directMatch = content.tables.find(
(table) => table.id === referenceId || table.code === referenceId,
);
return directMatch ?? findTableByCode(content, referenceId);
}
function resolveReferences(options: {
content: ContentPack;
run: RunState;
room: RoomState;
object: RoomObjectState;
references: ContentReference[];
at: string;
roller?: DiceRoller;
depth: number;
}) {
const logEntries: LogEntry[] = [];
const summary = { gold: 0, silver: 0, items: 0, damage: 0, healing: 0 };
if (options.depth > 1) {
return { logEntries, summary };
}
for (const reference of options.references) {
const quantity = reference.quantity ?? 1;
if (isAwardableReferenceType(reference.type)) {
awardEntry(options.run, reference.id, quantity);
options.run.lootedItems.push({
definitionId: reference.id,
quantity,
});
summary.items += quantity;
continue;
}
if (reference.type !== "table") {
continue;
}
const table = findTableByReference(options.content, reference.id);
for (let iteration = 0; iteration < quantity; iteration += 1) {
const lookup = lookupTable(table, { roller: options.roller });
const total = lookup.roll.modifiedTotal ?? lookup.roll.total;
const suffix = quantity > 1 ? ` (${iteration + 1}/${quantity})` : "";
logEntries.push(
createLogEntry(
`${options.room.id}.object.${options.object.id}.subroll.${table.code}.${options.depth}.${iteration + 1}`,
options.at,
"roll",
`Follow-up roll${suffix} ${lookup.roll.diceKind} [${lookup.roll.rolls.join(", ")}] on ${table.code} for ${total}: ${lookup.entry.label}.`,
[options.room.id, options.object.id, table.code],
),
);
for (const effect of lookup.entry.effects ?? []) {
const applied = applyRuleEffect(options.run, effect, options.roller);
summary.gold += applied.gold;
summary.silver += applied.silver;
summary.items += applied.items;
summary.damage += applied.damage;
summary.healing += applied.healing;
}
const nested = resolveReferences({
...options,
references: lookup.entry.references ?? [],
depth: options.depth + 1,
});
summary.gold += nested.summary.gold;
summary.silver += nested.summary.silver;
summary.items += nested.summary.items;
summary.damage += nested.summary.damage;
summary.healing += nested.summary.healing;
logEntries.push(...nested.logEntries);
logEntries.push(
createLogEntry(
`${options.room.id}.object.${options.object.id}.subresult.${table.code}.${options.depth}.${iteration + 1}`,
options.at,
"room",
`Follow-up result${suffix}: ${lookup.entry.text ?? lookup.entry.label}.`,
[options.room.id, options.object.id, table.code],
),
);
}
}
return { logEntries, summary };
}
export function searchRoom(
run: RunState,
room: RoomState,
at = new Date().toISOString(),
): SearchRoomResult {
room.discovery.searched = true;
const hiddenObjects = room.objects.filter((object) => object.hidden);
hiddenObjects.forEach((object) => {
object.hidden = false;
});
const logEntries =
hiddenObjects.length > 0
? hiddenObjects.map((object, index) =>
createLogEntry(
`${room.id}.search.${index + 1}`,
at,
"room",
`Searching ${room.id} reveals ${object.title}.`,
[room.id, object.id],
),
)
: [
createLogEntry(
`${room.id}.search.empty`,
at,
"room",
`Searched ${room.id} but found nothing new.`,
[room.id],
),
];
return {
run,
room,
logEntries,
};
}
export function resolveRoomObject(
options: ResolveRoomObjectOptions,
): ResolveRoomObjectResult {
const room = options.room;
const object = room.objects.find((entry) => entry.id === options.objectId);
if (!object) {
throw new Error(`Unknown room object id: ${options.objectId}`);
}
if (object.hidden) {
throw new Error(`Room object ${options.objectId} is still hidden.`);
}
if (object.interacted) {
throw new Error(`Room object ${options.objectId} has already been resolved.`);
}
const at = options.at ?? new Date().toISOString();
const logEntries: LogEntry[] = [];
object.interacted = true;
object.resolved = true;
if (object.sourceTableCode) {
const table = findTableByCode(options.content, object.sourceTableCode);
const lookup = lookupTable(table, { roller: options.roller });
const total = lookup.roll.modifiedTotal ?? lookup.roll.total;
const summary = { gold: 0, silver: 0, items: 0, damage: 0, healing: 0 };
object.resolutionLabel = lookup.entry.label;
object.resolutionEntryKey = lookup.entry.key;
logEntries.push(
createLogEntry(
`${room.id}.object.${object.id}.roll`,
at,
"roll",
`Rolled ${lookup.roll.diceKind} [${lookup.roll.rolls.join(", ")}] on ${object.sourceTableCode} for ${total}: ${lookup.entry.label}.`,
[room.id, object.id, object.sourceTableCode],
),
);
for (const effect of lookup.entry.effects ?? []) {
const applied = applyRuleEffect(options.run, effect, options.roller);
summary.gold += applied.gold;
summary.silver += applied.silver;
summary.items += applied.items;
summary.damage += applied.damage;
summary.healing += applied.healing;
}
const referenceResolution = resolveReferences({
content: options.content,
run: options.run,
room,
object,
references: lookup.entry.references ?? [],
at,
roller: options.roller,
depth: 0,
});
summary.gold += referenceResolution.summary.gold;
summary.items += referenceResolution.summary.items;
summary.damage += referenceResolution.summary.damage;
summary.healing += referenceResolution.summary.healing;
logEntries.push(...referenceResolution.logEntries);
logEntries.push(
createLogEntry(
`${room.id}.object.${object.id}.result`,
at,
"room",
`${object.title}: ${lookup.entry.text ?? lookup.entry.label}${summarizeOutcome(summary) ? ` (${summarizeOutcome(summary)})` : ""}.`,
[room.id, object.id, object.sourceTableCode],
),
);
return {
run: options.run,
room,
object,
logEntries,
};
}
const fallbackSummary = [
object.rewardGold ? `${object.rewardGold} gold` : undefined,
object.rewardItemId ? "1 item" : undefined,
object.damage ? `${object.damage} damage` : undefined,
].filter((entry): entry is string => Boolean(entry));
if (object.rewardGold) {
options.run.adventurerSnapshot.inventory.currency.gold += object.rewardGold;
options.run.goldGained += object.rewardGold;
}
if (object.rewardItemId) {
awardEntry(options.run, object.rewardItemId);
options.run.lootedItems.push({
definitionId: object.rewardItemId,
quantity: 1,
});
}
if (object.damage) {
options.run.adventurerSnapshot.hp.current = Math.max(
0,
options.run.adventurerSnapshot.hp.current - object.damage,
);
}
logEntries.push(
createLogEntry(
`${room.id}.object.${object.id}.result`,
at,
"room",
`${object.title} resolved${fallbackSummary.length > 0 ? `: ${fallbackSummary.join(", ")}.` : "."}`,
[room.id, object.id],
),
);
return {
run: options.run,
room,
object,
logEntries,
};
}
+2 -1
View File
@@ -8,6 +8,7 @@ import type { DungeonLevelState, RoomExitState, RoomState } from "@/types/state"
import { lookupTable, type TableLookupResult } from "./tables";
import type { DiceRoller } from "./dice";
import { createRoomObjectsFromTemplate } from "./roomObjects";
export type RoomGenerationOptions = {
content: ContentPack;
@@ -148,7 +149,7 @@ export function createRoomStateFromTemplate(
searched: false,
},
encounter: undefined,
objects: [],
objects: createRoomObjectsFromTemplate(template),
notes: [template.text ?? template.title, template.encounterText].filter(
(note): note is string => Boolean(note),
),
+360
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { sampleContentPack } from "@/data/sampleContentPack";
import { createStartingAdventurer } from "./character";
import { createRoomStateFromTemplate } from "./rooms";
import {
canCompleteCurrentLevel,
completeCurrentLevel,
@@ -10,12 +11,15 @@ import {
enterCurrentRoom,
getAvailableMoves,
isCurrentRoomCombatReady,
resolveCurrentRoomObject,
resolveRunEnemyTurn,
resolveRunPlayerTurn,
resumeDungeon,
returnToTown,
searchCurrentRoom,
startCombatInCurrentRoom,
travelCurrentExit,
useRunMagicItem,
} from "./runState";
function createSequenceRoller(values: number[]) {
@@ -372,6 +376,362 @@ describe("run state flow", () => {
expect(isCurrentRoomCombatReady(run)).toBe(true);
});
it("invokes Ring of Leaving to escape directly back to town", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
run.adventurerSnapshot.inventory.carried.push({
definitionId: "item.ring-of-leaving",
quantity: 1,
});
const result = useRunMagicItem({
content: sampleContentPack,
run,
definitionId: "item.ring-of-leaving",
at: "2026-03-19T22:30:00.000Z",
});
expect(result.run.phase).toBe("town");
expect(result.run.log.at(-1)?.text).toContain("Ring of Leaving");
});
it("uses Potion of Aura to reveal hidden room objects", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
const room = createRoomStateFromTemplate(
sampleContentPack,
"room.level1.aura-test",
1,
"room.level1.normal.abandoned-guard-post",
);
run.dungeon.levels["1"]!.rooms[room.id] = room;
run.dungeon.levels["1"]!.discoveredRoomOrder.push(room.id);
run.currentRoomId = room.id;
run.adventurerSnapshot.inventory.carried.push({
definitionId: "item.potion-of-aura",
quantity: 1,
});
const result = useRunMagicItem({
content: sampleContentPack,
run,
definitionId: "item.potion-of-aura",
at: "2026-03-19T22:31:00.000Z",
});
expect(result.run.dungeon.levels["1"]!.rooms[room.id]!.objects.every((object) => !object.hidden)).toBe(true);
expect(
result.run.adventurerSnapshot.inventory.carried.some((entry) => entry.definitionId === "item.potion-of-aura"),
).toBe(false);
});
it("uses Potion of Insightful Combat to improve the next attack", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
const room = run.dungeon.levels["1"]!.rooms["room.level1.start"]!;
room.encounter = {
id: `${room.id}.encounter`,
sourceTableCode: "L1CE",
creatureIds: ["a"],
creatureNames: ["Giant Rat"],
resultLabel: "Giant Rat",
resolved: true,
};
const withCombat = startCombatInCurrentRoom({
content: sampleContentPack,
run,
at: "2026-03-19T22:32:00.000Z",
}).run;
withCombat.adventurerSnapshot.inventory.carried.push({
definitionId: "item.potion-of-insightful-combat",
quantity: 1,
});
const buffed = useRunMagicItem({
content: sampleContentPack,
run: withCombat,
definitionId: "item.potion-of-insightful-combat",
at: "2026-03-19T22:33:00.000Z",
}).run;
const attacked = resolveRunPlayerTurn({
content: sampleContentPack,
run: buffed,
manoeuvreId: "manoeuvre.exact-strike",
targetEnemyId: buffed.activeCombat!.enemies[0]!.id,
roller: createSequenceRoller([2, 3, 1]),
at: "2026-03-19T22:34:00.000Z",
}).run;
expect(attacked.activeCombat).toBeUndefined();
expect(attacked.lastCombatOutcome?.result).toBe("victory");
expect(attacked.adventurerSnapshot.statuses.some((status) => status.id === "status.insightful-combat")).toBe(false);
});
it("uses Amulet of Resistance to reduce the next incoming hit", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
const room = run.dungeon.levels["1"]!.rooms["room.level1.start"]!;
room.encounter = {
id: `${room.id}.encounter`,
sourceTableCode: "L1G",
creatureIds: ["a"],
creatureNames: ["Guard"],
resultLabel: "Guard",
resolved: true,
};
const withCombat = startCombatInCurrentRoom({
content: sampleContentPack,
run,
at: "2026-03-19T22:35:00.000Z",
}).run;
withCombat.adventurerSnapshot.inventory.carried.push({
definitionId: "item.amulet-of-resistance",
quantity: 1,
});
const warded = useRunMagicItem({
content: sampleContentPack,
run: withCombat,
definitionId: "item.amulet-of-resistance",
at: "2026-03-19T22:36:00.000Z",
}).run;
warded.activeCombat!.actingSide = "enemy";
const afterEnemy = resolveRunEnemyTurn({
content: sampleContentPack,
run: warded,
roller: createSequenceRoller([6, 6]),
at: "2026-03-19T22:37:00.000Z",
}).run;
expect(afterEnemy.adventurerSnapshot.hp.current).toBe(withCombat.adventurerSnapshot.hp.current - 1);
});
it("uses Wand of Fire as a combat action and can finish the fight", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
const room = run.dungeon.levels["1"]!.rooms["room.level1.start"]!;
room.encounter = {
id: `${room.id}.encounter`,
sourceTableCode: "L1G",
creatureIds: ["a"],
creatureNames: ["Guard"],
resultLabel: "Guard",
resolved: true,
};
const withCombat = startCombatInCurrentRoom({
content: sampleContentPack,
run,
at: "2026-03-19T22:38:00.000Z",
}).run;
withCombat.activeCombat!.enemies[0]!.hpCurrent = 2;
withCombat.adventurerSnapshot.inventory.carried.push({
definitionId: "item.wand-of-fire",
quantity: 1,
});
const result = useRunMagicItem({
content: sampleContentPack,
run: withCombat,
definitionId: "item.wand-of-fire",
at: "2026-03-19T22:39:00.000Z",
});
expect(result.run.activeCombat).toBeUndefined();
expect(result.run.dungeon.levels["1"]!.rooms["room.level1.start"]!.discovery.cleared).toBe(true);
expect(result.run.lastCombatOutcome?.result).toBe("victory");
});
it("uses Ring of Spells to restore HP without consuming the ring", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
run.adventurerSnapshot.hp.current = 6;
run.adventurerSnapshot.inventory.carried.push({
definitionId: "item.ring-of-spells",
quantity: 1,
});
const result = useRunMagicItem({
content: sampleContentPack,
run,
definitionId: "item.ring-of-spells",
at: "2026-03-19T22:40:00.000Z",
});
expect(result.run.adventurerSnapshot.hp.current).toBe(8);
expect(
result.run.adventurerSnapshot.inventory.carried.some((entry) => entry.definitionId === "item.ring-of-spells"),
).toBe(true);
});
it("uses Amulet of Fire Resistance to absorb a stronger hit", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
const room = run.dungeon.levels["1"]!.rooms["room.level1.start"]!;
room.encounter = {
id: `${room.id}.encounter`,
sourceTableCode: "L1G",
creatureIds: ["a"],
creatureNames: ["Guard"],
resultLabel: "Guard",
resolved: true,
};
const withCombat = startCombatInCurrentRoom({
content: sampleContentPack,
run,
at: "2026-03-19T22:41:00.000Z",
}).run;
withCombat.adventurerSnapshot.inventory.carried.push({
definitionId: "item.amulet-of-fire-resistance",
quantity: 1,
});
const warded = useRunMagicItem({
content: sampleContentPack,
run: withCombat,
definitionId: "item.amulet-of-fire-resistance",
at: "2026-03-19T22:42:00.000Z",
}).run;
warded.activeCombat!.actingSide = "enemy";
const afterEnemy = resolveRunEnemyTurn({
content: sampleContentPack,
run: warded,
roller: createSequenceRoller([6, 6]),
at: "2026-03-19T22:43:00.000Z",
}).run;
expect(afterEnemy.adventurerSnapshot.hp.current).toBe(withCombat.adventurerSnapshot.hp.current);
});
it("uses Wand of Sleep to skip the next enemy turn", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
});
const room = run.dungeon.levels["1"]!.rooms["room.level1.start"]!;
room.encounter = {
id: `${room.id}.encounter`,
sourceTableCode: "L1G",
creatureIds: ["a"],
creatureNames: ["Guard"],
resultLabel: "Guard",
resolved: true,
};
const withCombat = startCombatInCurrentRoom({
content: sampleContentPack,
run,
at: "2026-03-19T22:44:00.000Z",
}).run;
withCombat.adventurerSnapshot.inventory.carried.push({
definitionId: "item.wand-of-sleep",
quantity: 1,
});
const slept = useRunMagicItem({
content: sampleContentPack,
run: withCombat,
definitionId: "item.wand-of-sleep",
at: "2026-03-19T22:45:00.000Z",
}).run;
const afterEnemy = resolveRunEnemyTurn({
content: sampleContentPack,
run: slept,
roller: createSequenceRoller([6, 6]),
at: "2026-03-19T22:46:00.000Z",
}).run;
expect(afterEnemy.adventurerSnapshot.hp.current).toBe(withCombat.adventurerSnapshot.hp.current);
expect(afterEnemy.activeCombat?.actingSide).toBe("player");
expect(afterEnemy.log.at(-1)?.text).toContain("sleeps through the turn");
});
it("supports searching and resolving room objects through run state", () => {
const run = createRunState({
content: sampleContentPack,
campaignId: "campaign.1",
adventurer: createAdventurer(),
at: "2026-03-15T14:00:00.000Z",
});
const room = run.dungeon.levels["1"]!.rooms["room.level1.start"]!;
room.objects = [
{
id: "room.level1.start.object.1",
objectType: "container",
title: "Hidden Cache",
sourceTableCode: "TCT1",
interacted: false,
resolved: false,
hidden: true,
searchable: true,
},
];
const searched = searchCurrentRoom(run, "2026-03-15T14:06:00.000Z").run;
expect(searched.dungeon.levels["1"]!.rooms["room.level1.start"]!.discovery.searched).toBe(true);
expect(searched.dungeon.levels["1"]!.rooms["room.level1.start"]!.objects[0]!.hidden).toBe(false);
const resolved = resolveCurrentRoomObject({
content: sampleContentPack,
run: searched,
objectId: "room.level1.start.object.1",
roller: () => 6,
at: "2026-03-15T14:07:00.000Z",
}).run;
expect(resolved.adventurerSnapshot.inventory.currency.gold).toBeGreaterThan(
searched.adventurerSnapshot.inventory.currency.gold,
);
expect(resolved.adventurerSnapshot.inventory.carried).toEqual(
expect.arrayContaining([expect.objectContaining({ definitionId: "item.garnet-ring" })]),
);
expect(
resolved.dungeon.levels["1"]!.rooms["room.level1.start"]!.objects[0]!.resolutionLabel,
).toBe("Garnet Ring and coins");
});
it("returns to town and later resumes the dungeon", () => {
const run = createRunState({
content: sampleContentPack,
+364
View File
@@ -12,6 +12,17 @@ import { startCombatFromRoom } from "./combat";
import { createInitialTownState } from "./townServices";
import { resolveCombatLoot } from "./loot";
import { applyLevelProgression } from "./progression";
import {
AMULET_FIRE_RESISTANCE_STATUS_ID,
AMULET_RESISTANCE_STATUS_ID,
INSIGHTFUL_COMBAT_STATUS_ID,
SLEEPING_STATUS_ID,
addStatus,
consumeCarriedItem,
getCarriedItemCount,
hasStatus,
revealHiddenObjects,
} from "./magicItems";
import {
resolveEnemyTurn,
resolvePlayerAttack,
@@ -28,6 +39,7 @@ import {
} from "./dungeon";
import type { DiceRoller } from "./dice";
import { enterRoom } from "./roomEntry";
import { resolveRoomObject, searchRoom } from "./roomObjects";
export type CreateRunOptions = {
content: ContentPack;
@@ -88,6 +100,22 @@ export type RunTransitionResult = {
logEntries: LogEntry[];
};
export type ResolveRoomObjectOptions = {
content: ContentPack;
run: RunState;
objectId: string;
roller?: DiceRoller;
at?: string;
};
export type UseRunMagicItemOptions = {
content: ContentPack;
run: RunState;
definitionId: string;
targetEnemyId?: string;
at?: string;
};
function appendDungeonFlag(run: RunState, flag: string) {
if (!run.dungeon.globalFlags.includes(flag)) {
run.dungeon.globalFlags.push(flag);
@@ -448,6 +476,7 @@ export function createRunState(options: CreateRunOptions): RunState {
defeatedCreatureIds: [],
xpGained: 0,
goldGained: 0,
silverGained: 0,
lootedItems: [],
log: [],
pendingEffects: [],
@@ -634,6 +663,62 @@ export function completeCurrentLevel(
};
}
export function searchCurrentRoom(
run: RunState,
at = new Date().toISOString(),
): RunTransitionResult {
const nextRun = cloneRun(run);
if (nextRun.phase !== "dungeon") {
throw new Error("Cannot search rooms while in town.");
}
if (nextRun.activeCombat) {
throw new Error("Cannot search rooms during active combat.");
}
const room = requireCurrentRoom(nextRun);
const result = searchRoom(nextRun, room, at);
appendLogs(nextRun, result.logEntries);
return {
run: nextRun,
logEntries: result.logEntries,
};
}
export function resolveCurrentRoomObject(
options: ResolveRoomObjectOptions,
): RunTransitionResult {
const nextRun = cloneRun(options.run);
if (nextRun.phase !== "dungeon") {
throw new Error("Cannot resolve room objects while in town.");
}
if (nextRun.activeCombat) {
throw new Error("Cannot resolve room objects during active combat.");
}
const room = requireCurrentRoom(nextRun);
const result = resolveRoomObject({
content: options.content,
run: nextRun,
room,
objectId: options.objectId,
roller: options.roller,
at: options.at,
});
appendLogs(nextRun, result.logEntries);
return {
run: nextRun,
logEntries: result.logEntries,
};
}
export function travelCurrentExit(
options: TravelCurrentExitOptions,
): RunTransitionResult {
@@ -879,3 +964,282 @@ export function resolveRunEnemyTurn(
logEntries: result.logEntries,
};
}
export function useRunMagicItem(
options: UseRunMagicItemOptions,
): RunTransitionResult {
const run = cloneRun(options.run);
const at = options.at ?? new Date().toISOString();
if (getCarriedItemCount(run, options.definitionId) === 0) {
throw new Error(`No carried ${options.definitionId} is available to use.`);
}
switch (options.definitionId) {
case "item.ring-of-leaving": {
if (run.phase !== "dungeon" || run.activeCombat) {
throw new Error("Ring of Leaving can only be invoked while exploring the dungeon.");
}
run.phase = "town";
run.lastTownAt = at;
run.townState.visits += 1;
const logEntry = createLogEntry(
`magic.ring-of-leaving.${run.log.length + 1}`,
at,
"town",
`Invoked Ring of Leaving and returned safely to town from level ${run.currentLevel}.`,
run.currentRoomId ? [run.currentRoomId, options.definitionId] : [options.definitionId],
);
appendLogs(run, [logEntry]);
return { run, logEntries: [logEntry] };
}
case "item.amulet-of-resistance": {
if (run.phase !== "dungeon") {
throw new Error("Amulet of Resistance can only be invoked in the dungeon.");
}
if (hasStatus(run.adventurerSnapshot.statuses, AMULET_RESISTANCE_STATUS_ID)) {
throw new Error("Amulet of Resistance is already warding the adventurer.");
}
const status = {
id: AMULET_RESISTANCE_STATUS_ID,
source: options.definitionId,
duration: run.activeCombat ? "combat" : "room",
value: 1,
notes: "Reduces the next damage taken by 1.",
} as const;
addStatus(run.adventurerSnapshot.statuses, { ...status });
if (run.activeCombat) {
addStatus(run.activeCombat.player.statuses, { ...status });
}
const logEntry = createLogEntry(
`magic.amulet-of-resistance.${run.log.length + 1}`,
at,
"progression",
"Invoked Amulet of Resistance. The next incoming damage will be reduced by 1.",
[options.definitionId],
);
appendLogs(run, [logEntry]);
return { run, logEntries: [logEntry] };
}
case "item.amulet-of-fire-resistance": {
if (run.phase !== "dungeon") {
throw new Error("Amulet of Fire Resistance can only be invoked in the dungeon.");
}
if (hasStatus(run.adventurerSnapshot.statuses, AMULET_FIRE_RESISTANCE_STATUS_ID)) {
throw new Error("Amulet of Fire Resistance is already warding the adventurer.");
}
const status = {
id: AMULET_FIRE_RESISTANCE_STATUS_ID,
source: options.definitionId,
duration: run.activeCombat ? "combat" : "room",
value: 2,
notes: "Reduces the next damage taken by 2.",
} as const;
addStatus(run.adventurerSnapshot.statuses, { ...status });
if (run.activeCombat) {
addStatus(run.activeCombat.player.statuses, { ...status });
}
const logEntry = createLogEntry(
`magic.amulet-of-fire-resistance.${run.log.length + 1}`,
at,
"progression",
"Invoked Amulet of Fire Resistance. The next incoming damage will be reduced by 2.",
[options.definitionId],
);
appendLogs(run, [logEntry]);
return { run, logEntries: [logEntry] };
}
case "item.ring-of-spells": {
const healed = Math.max(
0,
Math.min(2, run.adventurerSnapshot.hp.max - run.adventurerSnapshot.hp.current),
);
run.adventurerSnapshot.hp.current += healed;
if (run.activeCombat) {
run.activeCombat.player.hpCurrent = run.adventurerSnapshot.hp.current;
}
const logEntry = createLogEntry(
`magic.ring-of-spells.${run.log.length + 1}`,
at,
"progression",
`Ring of Spells releases a stored charm and restores ${healed} HP.`,
[options.definitionId],
);
appendLogs(run, [logEntry]);
return { run, logEntries: [logEntry] };
}
case "item.potion-of-aura": {
if (run.phase !== "dungeon" || run.activeCombat) {
throw new Error("Potion of Aura can only be used while exploring the dungeon.");
}
consumeCarriedItem(run, options.definitionId);
const room = requireCurrentRoom(run);
const revealed = revealHiddenObjects(room);
const logEntry = createLogEntry(
`magic.potion-of-aura.${run.log.length + 1}`,
at,
"room",
revealed.length > 0
? `Potion of Aura reveals ${revealed.map((entry) => entry.title).join(", ")} in the current room.`
: "Potion of Aura shimmers through the room, but reveals nothing new.",
[options.definitionId, room.id],
);
appendLogs(run, [logEntry]);
return { run, logEntries: [logEntry] };
}
case "item.potion-of-insightful-combat": {
if (!run.activeCombat || run.activeCombat.actingSide !== "player") {
throw new Error("Potion of Insightful Combat can only be used on the player's combat turn.");
}
if (hasStatus(run.activeCombat.player.statuses, INSIGHTFUL_COMBAT_STATUS_ID)) {
throw new Error("Insightful Combat is already active.");
}
consumeCarriedItem(run, options.definitionId);
const status = {
id: INSIGHTFUL_COMBAT_STATUS_ID,
source: options.definitionId,
duration: "combat",
value: 1,
notes: "Adds +1 precision to the next attack.",
} as const;
addStatus(run.adventurerSnapshot.statuses, { ...status });
addStatus(run.activeCombat.player.statuses, { ...status });
const logEntry = createLogEntry(
`magic.potion-of-insightful-combat.${run.log.length + 1}`,
at,
"combat",
"Potion of Insightful Combat sharpens the next attack with +1 precision.",
[options.definitionId, run.activeCombat.id],
);
appendLogs(run, [logEntry]);
return { run, logEntries: [logEntry] };
}
case "item.wand-of-fire": {
if (!run.activeCombat || run.activeCombat.actingSide !== "player") {
throw new Error("Wand of Fire can only be used on the player's combat turn.");
}
const target =
run.activeCombat.enemies.find((enemy) => enemy.id === options.targetEnemyId && enemy.hpCurrent > 0) ??
run.activeCombat.enemies.find((enemy) => enemy.hpCurrent > 0);
if (!target) {
throw new Error("No living enemy is available for Wand of Fire.");
}
target.hpCurrent = Math.max(0, target.hpCurrent - 2);
run.activeCombat.actingSide = run.activeCombat.enemies.some((enemy) => enemy.hpCurrent > 0)
? "enemy"
: "player";
const logEntries: LogEntry[] = [
createLogEntry(
`magic.wand-of-fire.${run.log.length + 1}`,
at,
"combat",
`Wand of Fire scorches ${target.name} for 2 damage.`,
[options.definitionId, target.id, run.activeCombat.id],
),
];
if (target.hpCurrent === 0) {
logEntries.push(
createLogEntry(
`magic.wand-of-fire.defeat.${run.log.length + 2}`,
at,
"combat",
`${target.name} is burned down by the wand's fire.`,
[options.definitionId, target.id, run.activeCombat.id],
),
);
}
appendLogs(run, logEntries);
run.activeCombat.combatLog.push(...logEntries);
syncPlayerToAdventurer(run);
if (run.activeCombat.enemies.every((enemy) => enemy.hpCurrent === 0)) {
const completedCombat = run.activeCombat;
const levelState = requireCurrentLevel(run);
const roomId = requireCurrentRoomId(run);
const room = levelState.rooms[roomId];
const rewardLogs = applyCombatRewards(
options.content,
run,
completedCombat,
undefined,
at,
);
if (room?.encounter) {
room.encounter.rewardPending = false;
room.discovery.cleared = true;
}
run.activeCombat = undefined;
appendLogs(run, rewardLogs);
}
return { run, logEntries };
}
case "item.wand-of-sleep": {
if (!run.activeCombat || run.activeCombat.actingSide !== "player") {
throw new Error("Wand of Sleep can only be used on the player's combat turn.");
}
const target =
run.activeCombat.enemies.find((enemy) => enemy.id === options.targetEnemyId && enemy.hpCurrent > 0) ??
run.activeCombat.enemies.find((enemy) => enemy.hpCurrent > 0);
if (!target) {
throw new Error("No living enemy is available for Wand of Sleep.");
}
if (!hasStatus(target.statuses, SLEEPING_STATUS_ID)) {
addStatus(target.statuses, {
id: SLEEPING_STATUS_ID,
source: options.definitionId,
duration: "combat",
value: 1,
notes: "Skips the next enemy turn.",
});
}
run.activeCombat.actingSide = "enemy";
const logEntry = createLogEntry(
`magic.wand-of-sleep.${run.log.length + 1}`,
at,
"combat",
`Wand of Sleep sends ${target.name} into a magical slumber.`,
[options.definitionId, target.id, run.activeCombat.id],
);
appendLogs(run, [logEntry]);
run.activeCombat.combatLog.push(logEntry);
return { run, logEntries: [logEntry] };
}
default:
throw new Error(`No magic-item action is implemented yet for ${options.definitionId}.`);
}
}