diff --git a/.gitignore b/.gitignore
index 99dcbd9..cc27ac3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ dist/
vite.config.js
vite.config.d.ts
Notes/rendered-pages/
+Notes/_codex_tables/
diff --git a/src/App.tsx b/src/App.tsx
index 3dcce53..2171059 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -20,14 +20,24 @@ import {
enterCurrentRoom,
getAvailableMoves,
isCurrentRoomCombatReady,
+ resolveCurrentRoomObject,
resolveRunEnemyTurn,
resolveRunPlayerTurn,
resumeDungeon,
returnToTown,
+ searchCurrentRoom,
startCombatInCurrentRoom,
travelCurrentExit,
+ useRunMagicItem,
} from "@/rules/runState";
import { getNextLevelXpThreshold, MAX_ADVENTURER_LEVEL } from "@/rules/progression";
+import {
+ AMULET_FIRE_RESISTANCE_STATUS_ID,
+ AMULET_RESISTANCE_STATUS_ID,
+ INSIGHTFUL_COMBAT_STATUS_ID,
+ getCarriedItemCount,
+ hasStatus,
+} from "@/rules/magicItems";
import {
getConsumableCounts,
restWithRation,
@@ -91,11 +101,24 @@ function getTownServiceDescription(serviceId: string) {
}
function getItemName(definitionId: string) {
- return sampleContentPack.items.find((item) => item.id === definitionId)?.name ?? definitionId;
+ return (
+ sampleContentPack.items.find((item) => item.id === definitionId)?.name ??
+ sampleContentPack.potions.find((potion) => potion.id === definitionId)?.name ??
+ sampleContentPack.scrolls.find((scroll) => scroll.id === definitionId)?.name ??
+ sampleContentPack.armour.find((armour) => armour.id === definitionId)?.name ??
+ sampleContentPack.weapons.find((weapon) => weapon.id === definitionId)?.name ??
+ definitionId
+ );
}
function getItemValue(definitionId: string) {
- return sampleContentPack.items.find((item) => item.id === definitionId)?.valueGp ?? 0;
+ return (
+ sampleContentPack.items.find((item) => item.id === definitionId)?.valueGp ??
+ sampleContentPack.potions.find((potion) => potion.id === definitionId)?.valueGp ??
+ sampleContentPack.scrolls.find((scroll) => scroll.id === definitionId)?.valueGp ??
+ sampleContentPack.armour.find((armour) => armour.id === definitionId)?.valueGp ??
+ 0
+ );
}
function getManoeuvreName(manoeuvreId: string) {
@@ -130,6 +153,23 @@ function App() {
0,
);
const consumableCounts = getConsumableCounts(run);
+ const magicItemCounts = {
+ ringOfLeaving: getCarriedItemCount(run, "item.ring-of-leaving"),
+ ringOfSpells: getCarriedItemCount(run, "item.ring-of-spells"),
+ amuletOfResistance: getCarriedItemCount(run, "item.amulet-of-resistance"),
+ amuletOfFireResistance: getCarriedItemCount(run, "item.amulet-of-fire-resistance"),
+ wandOfFire: getCarriedItemCount(run, "item.wand-of-fire"),
+ wandOfSleep: getCarriedItemCount(run, "item.wand-of-sleep"),
+ potionOfAura: getCarriedItemCount(run, "item.potion-of-aura"),
+ insightfulCombat: getCarriedItemCount(run, "item.potion-of-insightful-combat"),
+ };
+ const magicStatuses = {
+ resistance: hasStatus(run.adventurerSnapshot.statuses, AMULET_RESISTANCE_STATUS_ID),
+ fireResistance: hasStatus(run.adventurerSnapshot.statuses, AMULET_FIRE_RESISTANCE_STATUS_ID),
+ insight:
+ hasStatus(run.adventurerSnapshot.statuses, INSIGHTFUL_COMBAT_STATUS_ID) ||
+ hasStatus(run.activeCombat?.player.statuses ?? [], INSIGHTFUL_COMBAT_STATUS_ID),
+ };
const latestCombatLogs = run.activeCombat?.combatLog.slice(-3).reverse() ?? [];
const nextLevelXpThreshold =
run.adventurerSnapshot.level >= MAX_ADVENTURER_LEVEL
@@ -226,6 +266,20 @@ function App() {
updateRun((previous) => returnToTown(previous).run);
};
+ const handleSearchRoom = () => {
+ updateRun((previous) => searchCurrentRoom(previous).run);
+ };
+
+ const handleResolveRoomObject = (objectId: string) => {
+ updateRun((previous) =>
+ resolveCurrentRoomObject({
+ content: sampleContentPack,
+ run: previous,
+ objectId,
+ }).run,
+ );
+ };
+
const handleCompleteLevel = () => {
updateRun((previous) => completeCurrentLevel(previous).run);
};
@@ -325,6 +379,17 @@ function App() {
);
};
+ const handleUseMagicItem = (definitionId: string) => {
+ updateRun((previous) =>
+ useRunMagicItem({
+ content: sampleContentPack,
+ run: previous,
+ definitionId,
+ targetEnemyId: previous.activeCombat?.enemies.find((enemy) => enemy.hpCurrent > 0)?.id,
+ }).run,
+ );
+ };
+
const handleSaveCampaign = () => {
const storage = getBrowserStorage();
@@ -608,6 +673,118 @@ function App() {
Eat And Rest
+
+ Relic
+ Ring of Leaving
+
+ Escape straight back to town from the dungeon. Carried: {magicItemCounts.ringOfLeaving}
+
+ handleUseMagicItem("item.ring-of-leaving")}
+ disabled={inTown || Boolean(run.activeCombat) || magicItemCounts.ringOfLeaving === 0}
+ >
+ Invoke Ring
+
+
+
+ Relic
+ Ring of Spells
+
+ Release a stored charm to restore 2 HP. Carried: {magicItemCounts.ringOfSpells}
+
+ handleUseMagicItem("item.ring-of-spells")}
+ disabled={magicItemCounts.ringOfSpells === 0}
+ >
+ Invoke Ring
+
+
+
+ Relic
+ Amulet of Resistance
+
+ Reduce the next damage taken by 1. Carried: {magicItemCounts.amuletOfResistance}
+
+ handleUseMagicItem("item.amulet-of-resistance")}
+ disabled={inTown || magicItemCounts.amuletOfResistance === 0 || magicStatuses.resistance}
+ >
+ Raise Ward
+
+
+
+ Relic
+ Amulet of Fire Resistance
+
+ Reduce the next damage taken by 2. Carried: {magicItemCounts.amuletOfFireResistance}
+
+ handleUseMagicItem("item.amulet-of-fire-resistance")}
+ disabled={inTown || magicItemCounts.amuletOfFireResistance === 0 || magicStatuses.fireResistance}
+ >
+ Raise Fire Ward
+
+
+
+ Wand
+ Wand of Fire
+
+ Scorch the first living enemy for 2 damage. Carried: {magicItemCounts.wandOfFire}
+
+ handleUseMagicItem("item.wand-of-fire")}
+ disabled={!run.activeCombat || run.activeCombat.actingSide !== "player" || magicItemCounts.wandOfFire === 0}
+ >
+ Cast Fire
+
+
+
+ Wand
+ Wand of Sleep
+
+ Put the first living enemy to sleep for its next turn. Carried: {magicItemCounts.wandOfSleep}
+
+ handleUseMagicItem("item.wand-of-sleep")}
+ disabled={!run.activeCombat || run.activeCombat.actingSide !== "player" || magicItemCounts.wandOfSleep === 0}
+ >
+ Cast Sleep
+
+
+
+ Potion
+ Potion of Aura
+
+ Reveal hidden room objects while exploring. Carried: {magicItemCounts.potionOfAura}
+
+ handleUseMagicItem("item.potion-of-aura")}
+ disabled={inTown || Boolean(run.activeCombat) || magicItemCounts.potionOfAura === 0}
+ >
+ Drink Aura
+
+
+
+ Potion
+ Insightful Combat
+
+ Gain +1 precision on the next attack. Carried: {magicItemCounts.insightfulCombat}
+
+ handleUseMagicItem("item.potion-of-insightful-combat")}
+ disabled={!run.activeCombat || run.activeCombat.actingSide !== "player" || magicItemCounts.insightfulCombat === 0 || magicStatuses.insight}
+ >
+ Drink Combat Draft
+
+
@@ -665,6 +842,10 @@ function App() {
Current Gold
{run.adventurerSnapshot.inventory.currency.gold}
+
Rooms Found
{currentLevel?.discoveredRoomOrder.length ?? 0}
@@ -821,12 +1002,20 @@ function App() {
Entered: {currentRoom?.discovery.entered ? "Yes" : "No"}
Cleared: {currentRoom?.discovery.cleared ? "Yes" : "No"}
+ Searched: {currentRoom?.discovery.searched ? "Yes" : "No"}
Exits: {currentRoom?.exits.length ?? 0}
Enter Room
+
+ Search Room
+
Encounter
{currentRoom?.encounter?.resultLabel ?? "None"}
+
+
+
Room Objects
+ {currentRoom?.objects.filter((object) => !object.hidden).length ?? 0} visible
+
+ {!currentRoom || currentRoom.objects.filter((object) => !object.hidden).length === 0 ? (
+
No discovered objects in this room yet.
+ ) : (
+ currentRoom.objects
+ .filter((object) => !object.hidden)
+ .map((object) => (
+
+ {object.objectType}
+ {object.title}
+ {object.sourceTableCode ? {object.sourceTableCode} : null}
+
+ {object.resolutionLabel
+ ? `${object.notes ?? "Interact with this object to resolve its effect."} Last result: ${object.resolutionLabel}.`
+ : object.notes ?? "Interact with this object to resolve its effect."}
+
+ handleResolveRoomObject(object.id)}
+ disabled={object.interacted || Boolean(run.activeCombat)}
+ >
+ {object.interacted ? "Resolved" : "Resolve Object"}
+
+
+ ))
+ )}
+
{levelCompletionReady ? (
This room qualifies as the final cleared chamber. Completing the level will reveal
diff --git a/src/data/contentHelpers.test.ts b/src/data/contentHelpers.test.ts
index 8cff061..e6c8593 100644
--- a/src/data/contentHelpers.test.ts
+++ b/src/data/contentHelpers.test.ts
@@ -30,6 +30,20 @@ describe("level 1 content helpers", () => {
expect(table.entries).toHaveLength(6);
});
+ it("finds encoded level 1 room interaction tables by code", () => {
+ const table = findTableByCode(sampleContentPack, "CT1");
+
+ expect(table.name).toBe("Chest Table 1");
+ expect(table.diceKind).toBe("2d6");
+ expect(table.entries).toHaveLength(11);
+ });
+
+ it("finds newly encoded codex follow-up tables by code", () => {
+ expect(findTableByCode(sampleContentPack, "PT2GEM1").diceKind).toBe("d3");
+ expect(findTableByCode(sampleContentPack, "MR1").entries).toHaveLength(6);
+ expect(findTableByCode(sampleContentPack, "POT4").entries).toHaveLength(6);
+ });
+
it("resolves a small room template from a table lookup", () => {
const lookup = lookupTable(findTableByCode(sampleContentPack, "L1SR"), {
roller: createSequenceRoller([3, 4]),
diff --git a/src/data/level1RoomObjects.ts b/src/data/level1RoomObjects.ts
new file mode 100644
index 0000000..eb19f16
--- /dev/null
+++ b/src/data/level1RoomObjects.ts
@@ -0,0 +1,1296 @@
+import type { RoomObjectTemplate, TableDefinition } from "@/types/content";
+
+// These tables use codex table codes and page numbers from the PDF index.
+// The individual entries are MVP inferences until the scanned pages are fully transcribed.
+export const level1RoomInteractionTables: TableDefinition[] = [
+ {
+ id: "table.codex.ect1",
+ code: "ECT1",
+ name: "Empty Container Table 1",
+ category: "loot",
+ page: 15,
+ diceKind: "2d6",
+ entries: [
+ { key: "1-4", min: 1, max: 4, label: "Empty container" },
+ { key: "5", exact: 5, label: "Loose coin", effects: [{ type: "gain-gold", amount: 1, target: "self" }] },
+ { key: "6", exact: 6, label: "Forgotten trinket", references: [{ type: "item", id: "item.bone-charm" }] },
+ ],
+ notes: ["Codex-aligned container fallback for rooms that specify empty storage."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.bst1",
+ code: "BST1",
+ name: "Body Search Table 1",
+ category: "loot",
+ page: 20,
+ diceKind: "2d6",
+ entries: [
+ {
+ key: "2",
+ exact: 2,
+ label: "Spores erupt",
+ text: "The body stinks, and a cloud of spores erupts. Lose 1 HP.",
+ effects: [{ type: "take-damage", amount: 1, target: "self" }],
+ },
+ {
+ key: "3",
+ exact: 3,
+ label: "Body burns",
+ text: "The body bursts into flames destroying any loot. There is dark magic at work here.",
+ },
+ {
+ key: "4",
+ exact: 4,
+ label: "Nothing found",
+ text: "You pat down the body but find nothing.",
+ },
+ {
+ key: "5",
+ exact: 5,
+ label: "Snapped sword blade",
+ text: "There is a snapped off sword blade in the creature's ribcage.",
+ },
+ {
+ key: "6",
+ exact: 6,
+ label: "Wallet and leaves",
+ text: "Attached to a belt is a wallet. Inside you find some Malako leaves.",
+ references: [{ type: "item", id: "item.malako-leaves" }],
+ },
+ {
+ key: "7",
+ exact: 7,
+ label: "Golden chain",
+ text: "A golden chain around the corpse's neck. It is worth 30+10 GC.",
+ effects: [{ type: "gain-gold", amount: 40, target: "self" }],
+ },
+ {
+ key: "8",
+ exact: 8,
+ label: "Throwing axe +3",
+ text: "The creature's back is a throwing axe +3.",
+ },
+ {
+ key: "9",
+ exact: 9,
+ label: "Pouch containing 60 GC",
+ text: "Rolled up in a cloth are some sticks of charcoal and a quill feather. There is also a pouch containing 40+20 GC.",
+ effects: [{ type: "gain-gold", amount: 60, target: "self" }],
+ },
+ {
+ key: "10",
+ exact: 10,
+ label: "Pouch and potion roll",
+ text: "Inside the body you find a pouch. Inside is 20+10 GC and a POT2. There is also 20+10 GC in a pouch there.",
+ effects: [{ type: "gain-gold", amount: 60, target: "self" }],
+ references: [{ type: "table", id: "POT2" }],
+ },
+ {
+ key: "11",
+ exact: 11,
+ label: "Pouch with 90 GC",
+ text: "You search the body and find a pouch. Inside is 20+40+30 GC and a throwing axe.",
+ effects: [{ type: "gain-gold", amount: 90, target: "self" }],
+ },
+ {
+ key: "12",
+ exact: 12,
+ label: "Silver amulet with garnet",
+ text: "This amulet around the creature's neck is a HQ Garnet set in a silver frame.",
+ },
+ ],
+ notes: ["Exact BST1 entry text transcribed from the codex page image.", "Follow-up table calls and mixed loot types are not fully automated yet."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.bst2",
+ code: "BST2",
+ name: "Body Search Table 2",
+ category: "loot",
+ page: 20,
+ diceKind: "2d6",
+ entries: [
+ {
+ key: "2",
+ exact: 2,
+ label: "Gain the bloodied",
+ text: "Blood suddenly spurts from the body. Gain the bloodied status from core rules.",
+ },
+ {
+ key: "3",
+ exact: 3,
+ label: "Cloth disintegrates",
+ text: "The cloth disintegrates. You find nothing.",
+ },
+ {
+ key: "4",
+ exact: 4,
+ label: "Bags are empty",
+ text: "You rummage through the bags but find nothing.",
+ },
+ {
+ key: "5",
+ exact: 5,
+ label: "Throwing knife +1",
+ text: "On a shoulder strap is an old throwing knife +1.",
+ },
+ {
+ key: "6",
+ exact: 6,
+ label: "Bag of herbs",
+ text: "A bag lays beside the body. Inside you find twigs, Dankoma stems and scarlet ore leaves.",
+ },
+ {
+ key: "7",
+ exact: 7,
+ label: "Holster and 36 GC",
+ text: "The creature, in a leather holster, are two throwing knives. There is also a pouch with 30+6 GC.",
+ effects: [{ type: "gain-gold", amount: 36, target: "self" }],
+ },
+ {
+ key: "8",
+ exact: 8,
+ label: "Gold buckle worth 60 GC",
+ text: "A scarf on the corpse has a gold buckle worth 30+30 GC. It is set with 2 large MQ Rubies.",
+ effects: [{ type: "gain-gold", amount: 60, target: "self" }],
+ },
+ {
+ key: "9",
+ exact: 9,
+ label: "Throwing axe +2",
+ text: "Hanging from its clothing is a throwing axe +2.",
+ },
+ {
+ key: "10",
+ exact: 10,
+ label: "Pouch of MQ gems",
+ text: "There is a small pouch of MQ Gems. Roll on GMT1 x3.",
+ references: [{ type: "table", id: "GMT1", quantity: 3 }],
+ },
+ {
+ key: "11",
+ exact: 11,
+ label: "Power of invisibility",
+ text: "There is a bag and inside is an iron pocket. Inside is 3 doses of Power of Invisibility.",
+ references: [{ type: "potion", id: "potion.power-of-invisibility" }],
+ },
+ {
+ key: "12",
+ exact: 12,
+ label: "Rune stone",
+ text: "Inside a secret pocket you find a rune stone. Roll on RUNE2.",
+ },
+ ],
+ notes: ["Exact BST2 entry text transcribed from the codex page image.", "Follow-up table calls and statuses are not fully automated yet."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.ct1",
+ code: "CT1",
+ name: "Chest Table 1",
+ category: "loot",
+ page: 21,
+ diceKind: "2d6",
+ entries: [
+ {
+ key: "2",
+ exact: 2,
+ label: "Chest is empty",
+ text: "You lift the lid and find the box empty, apart from some old rags.",
+ },
+ {
+ key: "3",
+ exact: 3,
+ label: "Two old carrots",
+ text: "The lid slides to one side and inside you see two old carrots.",
+ },
+ {
+ key: "4",
+ exact: 4,
+ label: "Twisted metal and split bone",
+ text: "The box is full of twisted pieces of metal and a split bone.",
+ },
+ {
+ key: "5",
+ exact: 5,
+ label: "Sack of firewood",
+ text: "The chest contains a sack full of firewood, some of which is wrapped in paper.",
+ },
+ {
+ key: "6",
+ exact: 6,
+ label: "Selection of dried fish",
+ text: "There is a selection of dried fish which will make 1 ration. There is also a Lock Pick +2 (4).",
+ },
+ {
+ key: "7",
+ exact: 7,
+ label: "Cabbage, bandages and potion",
+ text: "The chest contains a selection of things including two bandages, a Potion of Healing and a cabbage.",
+ references: [{ type: "potion", id: "potion.healing" }],
+ },
+ {
+ key: "8",
+ exact: 8,
+ label: "Vial and leaves",
+ text: "Inside the chest is a vial and some Malako leaves. There is 60 GC as well as a Lock Pick +3 (2).",
+ effects: [{ type: "gain-gold", amount: 60, target: "self" }],
+ },
+ {
+ key: "9",
+ exact: 9,
+ label: "Belt worth 20 SC and pearls",
+ text: "At the bottom is a belt with D6 LQ pearls, a throwing dart and a parsnip.",
+ effects: [{ type: "gain-silver", amount: 20, target: "self" }],
+ },
+ {
+ key: "10",
+ exact: 10,
+ label: "Luminous stone and scroll",
+ text: "Also there, among all this is a luminous stone and a scroll, roll on SCT1 and roll on HAOIT1.",
+ references: [
+ { type: "table", id: "SCT1" },
+ { type: "table", id: "HAOIT1" },
+ ],
+ },
+ {
+ key: "11",
+ exact: 11,
+ label: "Large pouch with 36 GC",
+ text: "Throwing axe. Beside it is a large pouch. Inside are 30+6 GC. There is also a belt necklace and a pouch containing 20+40 SC and 6D6 LQ rubies.",
+ effects: [
+ { type: "gain-gold", amount: 36, target: "self" },
+ { type: "gain-silver", amount: 60, target: "self" },
+ ],
+ },
+ {
+ key: "12",
+ exact: 12,
+ label: "Garnet and two tubes",
+ text: "The bottom contains a HQ Garnet and a pouch containing 50 GC. There are also 6D6 LQ tubes.",
+ effects: [{ type: "gain-gold", amount: 50, target: "self" }],
+ },
+ ],
+ notes: ["Exact CT1 entry text transcribed from the codex page image.", "Mixed currencies and many follow-up item tables are not fully automated yet."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.ct2",
+ code: "CT2",
+ name: "Chest Table 2",
+ category: "loot",
+ page: 21,
+ diceKind: "2d6",
+ entries: [
+ {
+ key: "2",
+ exact: 2,
+ label: "Chest full of earth and stone",
+ text: "The container is full of earth and stone.",
+ },
+ {
+ key: "3",
+ exact: 3,
+ label: "Leather sack and cheese",
+ text: "The box contains an empty leather sack and a scattering of metal.",
+ },
+ {
+ key: "4",
+ exact: 4,
+ label: "Cloth wrapped cheese",
+ text: "Inside the chest, wrapped in a cloth are some slices of cheese.",
+ },
+ {
+ key: "5",
+ exact: 5,
+ label: "Metal rod and rope",
+ text: "There is some metal rod spikes and a length of rope. Inside the chest are the remains of the cheese.",
+ },
+ {
+ key: "6",
+ exact: 6,
+ label: "4 throwing knives",
+ text: "As well as some valuables there is 4 throwing knives. At the bottom is a casket full of 20D6+10 SC.",
+ effects: [{ type: "gain-silver", amount: 10, diceKind: "d6", rollCount: 20, target: "self" }],
+ },
+ {
+ key: "7",
+ exact: 7,
+ label: "Bone lock pick and armour roll",
+ text: "Inside is a broken necklace. At the bottom is a piece of armour roll on ART1.",
+ references: [{ type: "table", id: "ART1" }],
+ },
+ {
+ key: "8",
+ exact: 8,
+ label: "See all magic items",
+ text: "You find a selection of loot items. Among it you find 20+20 SC, a gold ring worth D6 GC and a throwing axe.",
+ effects: [
+ { type: "gain-silver", amount: 40, target: "self" },
+ { type: "gain-gold", diceKind: "d6", target: "self" },
+ ],
+ },
+ {
+ key: "9",
+ exact: 9,
+ label: "Bracelet worth 10 GC",
+ text: "Inside is a selection of ropes and two brown eggs. It also contains 20+20 SC and a bracelet worth 6+10 GC.",
+ effects: [
+ { type: "gain-gold", amount: 10, diceKind: "d6", target: "self" },
+ { type: "gain-silver", amount: 40, target: "self" },
+ ],
+ },
+ {
+ key: "10",
+ exact: 10,
+ label: "Smooth luminous stone and relic rolls",
+ text: "An Amulet of Intuneric. There is also a small smooth luminous stone. Roll on 2 HAOITs.",
+ references: [{ type: "table", id: "HAOIT1", quantity: 2 }],
+ },
+ {
+ key: "11",
+ exact: 11,
+ label: "6 throwing knives and potion",
+ text: "There is a sack, the contents are 6 throwing knives and a potion. Roll on POT2.",
+ references: [{ type: "table", id: "POT2" }],
+ },
+ {
+ key: "12",
+ exact: 12,
+ label: "Armour meat and ART2",
+ text: "Mixed dried meat and a pile of armour. Roll on ART2.",
+ references: [{ type: "table", id: "ART2" }],
+ },
+ ],
+ notes: ["Exact CT2 entry text transcribed from the codex page image.", "Several results still depend on follow-up tables not yet encoded."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.pt1",
+ code: "PT1",
+ name: "Pouch Table 1",
+ category: "loot",
+ page: 23,
+ diceKind: "2d6",
+ entries: [
+ {
+ key: "2",
+ exact: 2,
+ label: "Damaged pouch",
+ text: "Nothing, but it is damp and when you sniff your fingers, there is a strange smell.",
+ },
+ {
+ key: "3",
+ exact: 3,
+ label: "Pouch is empty",
+ text: "Nothing, The pouch is empty.",
+ },
+ {
+ key: "4",
+ exact: 4,
+ label: "Pouch is empty",
+ text: "Nothing, The pouch is empty.",
+ },
+ {
+ key: "5",
+ exact: 5,
+ label: "Dried pumpkin seeds",
+ text: "There are some dried pumpkin seeds in the pouch.",
+ },
+ {
+ key: "6",
+ exact: 6,
+ label: "D6 SC in pouch",
+ text: "There are D6 SC inside the pouch.",
+ effects: [{ type: "gain-silver", diceKind: "d6", target: "self" }],
+ },
+ {
+ key: "7",
+ exact: 7,
+ label: "60 SC rattling in pouch",
+ text: "A few coins rattle in the pouch. Gain 40 SC and 20D6 SC.",
+ effects: [{ type: "gain-silver", amount: 40, diceKind: "d6", rollCount: 20, target: "self" }],
+ },
+ {
+ key: "8",
+ exact: 8,
+ label: "Malako leaves",
+ text: "There are some Malako leaves inside.",
+ references: [{ type: "item", id: "item.malako-leaves" }],
+ },
+ {
+ key: "9",
+ exact: 9,
+ label: "Coins in pouch",
+ text: "There are some coins in the pouch. Gain D6+25 SC and D6 GC.",
+ effects: [
+ { type: "gain-silver", amount: 25, diceKind: "d6", target: "self" },
+ { type: "gain-gold", diceKind: "d6", target: "self" },
+ ],
+ },
+ {
+ key: "10",
+ exact: 10,
+ label: "Luminous leaves and gems",
+ text: "There are some 7luminous leaves and two LQ gems. Roll twice on GMT1.",
+ references: [{ type: "table", id: "GMT1", quantity: 2 }],
+ },
+ {
+ key: "11",
+ exact: 11,
+ label: "Golden buckle and potion",
+ text: "Inside is a golden buckle worth D6+2 GC and a potion. Roll on POT2.",
+ effects: [{ type: "gain-gold", amount: 2, diceKind: "d6", target: "self" }],
+ references: [{ type: "table", id: "POT2" }],
+ },
+ {
+ key: "12",
+ exact: 12,
+ label: "Crammed with coins",
+ text: "This is crammed with coins. You count 2D6 SC and D6+3 GC.",
+ effects: [
+ { type: "gain-silver", diceKind: "d6", rollCount: 2, target: "self" },
+ { type: "gain-gold", amount: 3, diceKind: "d6", target: "self" },
+ ],
+ },
+ ],
+ notes: ["Exact PT1 entry text transcribed from the codex page image.", "Silver-coin results and follow-up tables are not fully automated yet."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.pt2",
+ code: "PT2",
+ name: "Pouch Table 2",
+ category: "loot",
+ page: 23,
+ diceKind: "2d6",
+ entries: [
+ {
+ key: "2",
+ exact: 2,
+ label: "Hole in pouch",
+ text: "Nothing. In fact, there is a hole in the bottom making it useless.",
+ },
+ {
+ key: "3",
+ exact: 3,
+ label: "Pouch is empty",
+ text: "Nothing. The pouch is empty.",
+ },
+ {
+ key: "4",
+ exact: 4,
+ label: "Pouch is empty",
+ text: "Nothing. The pouch is empty.",
+ },
+ {
+ key: "5",
+ exact: 5,
+ label: "20+5 SC coins",
+ text: "There are 20+5 SC coins in the pouch.",
+ effects: [{ type: "gain-silver", amount: 25, target: "self" }],
+ },
+ {
+ key: "6",
+ exact: 6,
+ label: "Scattered Oretauts leaves",
+ text: "There are some scattered Oretauts leaves inside.",
+ },
+ {
+ key: "7",
+ exact: 7,
+ label: "Gain 30 SC and 2D6 GC",
+ text: "A few coins rattle in the pouch. Gain 30 SC and 2D6 GC.",
+ effects: [
+ { type: "gain-silver", amount: 30, target: "self" },
+ { type: "gain-gold", diceKind: "d6", rollCount: 2, target: "self" },
+ ],
+ },
+ {
+ key: "8",
+ exact: 8,
+ label: "Lock Pick +2 and Malako leaves",
+ text: "Tucked inside is a Lock Pick +2 (2) and some Malako leaves.",
+ references: [{ type: "item", id: "item.malako-leaves" }],
+ },
+ {
+ key: "9",
+ exact: 9,
+ label: "Wrapped cloth and petals",
+ text: "Wrapped in a piece of cloth inside are some Malako leaves and lily petals.",
+ },
+ {
+ key: "10",
+ exact: 10,
+ label: "Gain 20 GC and gem",
+ text: "It has some loose coins, 20 GC and a random gem. Roll D3, 1 = a MQ sapphire, 2 = a MQ garnet, 3 = a MQ ruby.",
+ effects: [{ type: "gain-gold", amount: 20, target: "self" }],
+ references: [{ type: "table", id: "PT2GEM1" }],
+ },
+ {
+ key: "11",
+ exact: 11,
+ label: "Potion and HST1 herbs",
+ text: "Inside is a potion, roll on POT3 and some herbs roll on HST1.",
+ references: [
+ { type: "table", id: "POT3" },
+ { type: "table", id: "HST1" },
+ ],
+ },
+ {
+ key: "12",
+ exact: 12,
+ label: "Broken ornate metal item",
+ text: "The pouch contains a HQ Ornate and broken metal item. Roll on HAOIT1.",
+ references: [{ type: "table", id: "HAOIT1" }],
+ },
+ ],
+ notes: ["Exact PT2 entry text transcribed from the codex page image.", "Silver-coin results and follow-up tables are not fully automated yet."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.pt2gem1",
+ code: "PT2GEM1",
+ name: "PT2 Random Gem Table 1",
+ category: "random-list",
+ page: 23,
+ diceKind: "d3",
+ entries: [
+ { key: "1", exact: 1, label: "MQ Sapphire", references: [{ type: "item", id: "item.sapphire" }] },
+ { key: "2", exact: 2, label: "MQ Garnet", references: [{ type: "item", id: "item.garnet" }] },
+ { key: "3", exact: 3, label: "MQ Ruby", references: [{ type: "item", id: "item.ruby" }] },
+ ],
+ notes: ["Supports the PT2 random-gem result with an explicit D3 follow-up roll."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.hst1",
+ code: "HST1",
+ name: "Herbs Table 1",
+ category: "random-list",
+ page: 15,
+ diceKind: "d6",
+ entries: [
+ { key: "1", exact: 1, label: "Redroot Spines", references: [{ type: "item", id: "item.redroot-spines" }] },
+ { key: "2", exact: 2, label: "Dankoma Stems", references: [{ type: "item", id: "item.dankoma-stems" }] },
+ { key: "3", exact: 3, label: "Wolf Worm Eggs", references: [{ type: "item", id: "item.wolf-worm-eggs" }] },
+ { key: "4", exact: 4, label: "Malako Leaves", references: [{ type: "item", id: "item.malako-leaves" }] },
+ { key: "5", exact: 5, label: "Scarlet Ore Leaves", references: [{ type: "item", id: "item.scarlet-ore-leaves" }] },
+ { key: "6", exact: 6, label: "Oretauts Leaves", references: [{ type: "item", id: "item.oretauts-leaves" }] },
+ ],
+ notes: ["Exact HST1 names transcribed from the codex page image."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.gmt1",
+ code: "GMT1",
+ name: "Gem Table 1",
+ category: "random-list",
+ page: 15,
+ diceKind: "d6",
+ entries: [
+ { key: "1", exact: 1, label: "Pearl", references: [{ type: "item", id: "item.pearl" }] },
+ { key: "2", exact: 2, label: "Sapphire", references: [{ type: "item", id: "item.sapphire" }] },
+ { key: "3", exact: 3, label: "Garnet", references: [{ type: "item", id: "item.garnet" }] },
+ { key: "4", exact: 4, label: "Ruby", references: [{ type: "item", id: "item.ruby" }] },
+ { key: "5", exact: 5, label: "Emerald", references: [{ type: "item", id: "item.emerald" }] },
+ { key: "6", exact: 6, label: "Diamond", references: [{ type: "item", id: "item.diamond" }] },
+ ],
+ notes: ["Exact GMT1 names transcribed from the codex page image."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.got1",
+ code: "GOT1",
+ name: "God Ornates Table 1",
+ category: "random-list",
+ page: 15,
+ diceKind: "d6",
+ entries: [
+ { key: "1", exact: 1, label: "Goada the Helm", references: [{ type: "item", id: "item.god-ornate-goada" }] },
+ { key: "2", exact: 2, label: "Intuneric the Murk", references: [{ type: "item", id: "item.god-ornate-intuneric" }] },
+ { key: "3", exact: 3, label: "Murtayne the Pup", references: [{ type: "item", id: "item.god-ornate-murtayne" }] },
+ { key: "4", exact: 4, label: "Nevzator the Blind", references: [{ type: "item", id: "item.god-ornate-nevzator" }] },
+ { key: "5", exact: 5, label: "Radacina the X", references: [{ type: "item", id: "item.god-ornate-radacina" }] },
+ { key: "6", exact: 6, label: "Madi the Sphere", references: [{ type: "item", id: "item.god-ornate-madi" }] },
+ ],
+ notes: ["Names transcribed from the codex image as closely as currently legible."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.haoit1",
+ code: "HAOIT1",
+ name: "Half an Ornate Item Table 1",
+ category: "random-list",
+ page: 15,
+ diceKind: "d6",
+ entries: [
+ { key: "1", exact: 1, label: "Half a Copper Pendant", references: [{ type: "item", id: "item.half-copper-pendant" }] },
+ { key: "2", exact: 2, label: "Half a Gold Pendant worth D6 GC", references: [{ type: "item", id: "item.half-gold-pendant" }] },
+ { key: "3", exact: 3, label: "Half a Gold Cross worth 2D6 GC", references: [{ type: "item", id: "item.half-gold-cross" }] },
+ { key: "4", exact: 4, label: "Half a Silver Cross worth 2D6 GC", references: [{ type: "item", id: "item.half-silver-cross" }] },
+ { key: "5", exact: 5, label: "Half a Gold Symbol worth 4D6 GC", references: [{ type: "item", id: "item.half-gold-symbol" }] },
+ { key: "6", exact: 6, label: "Half a Gold Symbol worth 40+6 GC", references: [{ type: "item", id: "item.half-gold-symbol-high" }] },
+ ],
+ notes: ["Exact HAOIT1 names transcribed from the codex page image."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.art1",
+ code: "ART1",
+ name: "Armour Table 1",
+ category: "random-list",
+ page: 15,
+ diceKind: "d6",
+ entries: [
+ { key: "1", exact: 1, label: "Padded Tunic", references: [{ type: "item", id: "item.padded-tunic" }] },
+ { key: "2", exact: 2, label: "Silk Tunic", references: [{ type: "item", id: "item.padded-tunic" }] },
+ { key: "3", exact: 3, label: "Hide Doublet", references: [{ type: "item", id: "item.hide-doublet" }] },
+ { key: "4", exact: 4, label: "Hide Doublet", references: [{ type: "item", id: "item.hide-doublet-alt" }] },
+ { key: "5", exact: 5, label: "Bishops Mail", references: [{ type: "item", id: "item.bishops-mail" }] },
+ { key: "6", exact: 6, label: "Morning Jacket", references: [{ type: "item", id: "item.morning-jacket" }] },
+ ],
+ notes: ["Exact ART1 names transcribed from the codex page image."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.art2",
+ code: "ART2",
+ name: "Armour Table 2",
+ category: "random-list",
+ page: 15,
+ diceKind: "d6",
+ entries: [
+ { key: "1", exact: 1, label: "Leather Breastplate", references: [{ type: "item", id: "item.leather-breastplate" }] },
+ { key: "2", exact: 2, label: "Leather Bracers", references: [{ type: "item", id: "item.leather-bracers" }] },
+ { key: "3", exact: 3, label: "Brigandine Coat", references: [{ type: "item", id: "item.brigandine-coat" }] },
+ { key: "4", exact: 4, label: "Hide Doublet", references: [{ type: "item", id: "item.hide-doublet-alt" }] },
+ { key: "5", exact: 5, label: "Leather Breastplate", references: [{ type: "item", id: "item.leather-breastplate" }] },
+ { key: "6", exact: 6, label: "Woden Shield", references: [{ type: "item", id: "item.woden-shield" }] },
+ ],
+ notes: ["Exact ART2 names transcribed from the codex page image."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.mr1",
+ code: "MR1",
+ name: "Magic Ring Table 1",
+ category: "random-list",
+ page: 16,
+ diceKind: "d6",
+ entries: [
+ { key: "1", exact: 1, label: "Encountered Ring", references: [{ type: "item", id: "item.ring-encountered" }] },
+ { key: "2", exact: 2, label: "Ring of Baseness", references: [{ type: "item", id: "item.ring-of-baseness" }] },
+ { key: "3", exact: 3, label: "Ring of Spells", references: [{ type: "item", id: "item.ring-of-spells" }] },
+ { key: "4", exact: 4, label: "Ring of Steadiness", references: [{ type: "item", id: "item.ring-of-steadiness" }] },
+ { key: "5", exact: 5, label: "Ring of Transformation", references: [{ type: "item", id: "item.ring-of-transformation" }] },
+ { key: "6", exact: 6, label: "Ring of Leaving", references: [{ type: "item", id: "item.ring-of-leaving" }] },
+ ],
+ notes: ["Exact MR1 names transcribed from the codex page image."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.ma1",
+ code: "MA1",
+ name: "Magical Amulet Table 1",
+ category: "random-list",
+ page: 16,
+ diceKind: "d6",
+ entries: [
+ { key: "1", exact: 1, label: "Amulet of Resistance", references: [{ type: "item", id: "item.amulet-of-resistance" }] },
+ { key: "2", exact: 2, label: "Amulet of Fire Resistance", references: [{ type: "item", id: "item.amulet-of-fire-resistance" }] },
+ { key: "3", exact: 3, label: "Amulet of Fire Resistance", references: [{ type: "item", id: "item.amulet-of-fire-resistance" }] },
+ { key: "4", exact: 4, label: "Amulet of Ice Resistance", references: [{ type: "item", id: "item.amulet-of-ice-resistance" }] },
+ { key: "5", exact: 5, label: "Amulet of Resistance", references: [{ type: "item", id: "item.amulet-of-resistance" }] },
+ { key: "6", exact: 6, label: "Amulet of Poison Resistance", references: [{ type: "item", id: "item.amulet-of-poison-resistance" }] },
+ ],
+ notes: ["Exact MA1 names transcribed from the codex page image."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.mw1",
+ code: "MW1",
+ name: "Magic Wand Table 1",
+ category: "random-list",
+ page: 16,
+ diceKind: "d6",
+ entries: [
+ { key: "1", exact: 1, label: "Wand of Fireballs", references: [{ type: "item", id: "item.wand-of-fireballs" }] },
+ { key: "2", exact: 2, label: "Wand of Fireballs", references: [{ type: "item", id: "item.wand-of-fireballs" }] },
+ { key: "3", exact: 3, label: "Wand of Fire", references: [{ type: "item", id: "item.wand-of-fire" }] },
+ { key: "4", exact: 4, label: "Wand of Sunder", references: [{ type: "item", id: "item.wand-of-sunder" }] },
+ { key: "5", exact: 5, label: "Wand of Sleep", references: [{ type: "item", id: "item.wand-of-sleep" }] },
+ { key: "6", exact: 6, label: "Wand of Paralysis", references: [{ type: "item", id: "item.wand-of-paralysis" }] },
+ ],
+ notes: ["Exact MW1 names transcribed from the codex page image."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.pot2",
+ code: "POT2",
+ name: "Potions Table 2",
+ category: "random-list",
+ page: 16,
+ diceKind: "d6",
+ entries: [
+ { key: "1", exact: 1, label: "Potion of Extra Healing", references: [{ type: "potion", id: "potion.extra-healing" }] },
+ { key: "2", exact: 2, label: "Potion of Prowess", references: [{ type: "potion", id: "potion.prowess" }] },
+ { key: "3", exact: 3, label: "Potion of Mighty Strength", references: [{ type: "potion", id: "potion.mighty-strength" }] },
+ { key: "4", exact: 4, label: "Potion of Gain Health", references: [{ type: "potion", id: "potion.gain-health" }] },
+ { key: "5", exact: 5, label: "Potion of Finesse", references: [{ type: "potion", id: "potion.finesse" }] },
+ { key: "6", exact: 6, label: "Potion of Finesse", references: [{ type: "potion", id: "potion.finesse-alt" }] },
+ ],
+ notes: ["Exact POT2 names transcribed from the codex page image."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.pot1",
+ code: "POT1",
+ name: "Potions Table 1",
+ category: "random-list",
+ page: 16,
+ diceKind: "d6",
+ entries: [
+ { key: "1", exact: 1, label: "Potion of No Healing", references: [{ type: "potion", id: "potion.no-healing" }] },
+ { key: "2", exact: 2, label: "Potion of Healing", references: [{ type: "potion", id: "potion.healing-alt" }] },
+ { key: "3", exact: 3, label: "Potion of Examination", references: [{ type: "potion", id: "potion.examination" }] },
+ { key: "4", exact: 4, label: "Potion of Strength", references: [{ type: "potion", id: "potion.strength" }] },
+ { key: "5", exact: 5, label: "Potion of Swamp Lung", references: [{ type: "item", id: "item.potion-of-swamp-lung" }] },
+ { key: "6", exact: 6, label: "Potion of Aura", references: [{ type: "item", id: "item.potion-of-aura" }] },
+ ],
+ notes: ["Exact POT1 names transcribed from the codex page image."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.pot3",
+ code: "POT3",
+ name: "Potions Table 3",
+ category: "random-list",
+ page: 16,
+ diceKind: "d6",
+ entries: [
+ { key: "1", exact: 1, label: "Potion of Finesse", references: [{ type: "potion", id: "potion.finesse-3" }] },
+ { key: "2", exact: 2, label: "Potion of Gain Health", references: [{ type: "potion", id: "potion.gain-health-alt" }] },
+ { key: "3", exact: 3, label: "Potion of Gain Health", references: [{ type: "potion", id: "potion.gain-health-2" }] },
+ { key: "4", exact: 4, label: "Potion of Divine Shield", references: [{ type: "potion", id: "potion.divine-shield" }] },
+ { key: "5", exact: 5, label: "Potion of Willpower", references: [{ type: "potion", id: "potion.willpower" }] },
+ { key: "6", exact: 6, label: "Potion of Willpower", references: [{ type: "potion", id: "potion.willpower" }] },
+ ],
+ notes: ["Exact POT3 names transcribed from the codex page image."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.pot4",
+ code: "POT4",
+ name: "Potions Table 4",
+ category: "random-list",
+ page: 16,
+ diceKind: "d6",
+ entries: [
+ { key: "1", exact: 1, label: "Further Healing", references: [{ type: "potion", id: "potion.further-healing" }] },
+ { key: "2", exact: 2, label: "Potion of Healing", references: [{ type: "potion", id: "potion.healing-4" }] },
+ { key: "3", exact: 3, label: "Potion of Steadiness", references: [{ type: "potion", id: "potion.steadiness" }] },
+ { key: "4", exact: 4, label: "Potion of Domination", references: [{ type: "potion", id: "potion.domination" }] },
+ { key: "5", exact: 5, label: "Potion of Insightful Combat", references: [{ type: "item", id: "item.potion-of-insightful-combat" }] },
+ { key: "6", exact: 6, label: "Potion of Insightful Combat", references: [{ type: "item", id: "item.potion-of-insightful-combat" }] },
+ ],
+ notes: ["Exact POT4 names transcribed from the codex page image."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.sct1",
+ code: "SCT1",
+ name: "Scrolls Table 1",
+ category: "random-list",
+ page: 16,
+ diceKind: "d6",
+ entries: [
+ { key: "1", exact: 1, label: "Scroll of Balance", references: [{ type: "scroll", id: "scroll.balance" }] },
+ { key: "2", exact: 2, label: "Scroll of Reading", references: [{ type: "scroll", id: "scroll.reading" }] },
+ { key: "3", exact: 3, label: "Scroll of Brute Force", references: [{ type: "scroll", id: "scroll.brute-force" }] },
+ { key: "4", exact: 4, label: "Scroll of Ignite", references: [{ type: "scroll", id: "scroll.ignite" }] },
+ { key: "5", exact: 5, label: "Scroll of Mental Whip", references: [{ type: "scroll", id: "scroll.mental-whip" }] },
+ { key: "6", exact: 6, label: "Scroll of Paralysis", references: [{ type: "scroll", id: "scroll.paralysis" }] },
+ ],
+ notes: ["Exact SCT1 names transcribed from the codex page image."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.rupt1",
+ code: "RUPT1",
+ name: "Rubbish Pile Table 1",
+ category: "loot",
+ page: 27,
+ diceKind: "d6",
+ entries: [
+ { key: "1-3", min: 1, max: 3, label: "Broken scraps" },
+ { key: "4", exact: 4, label: "Useful ration", references: [{ type: "item", id: "item.ration" }] },
+ { key: "5", exact: 5, label: "Hidden coin", effects: [{ type: "gain-gold", amount: 1, target: "self" }] },
+ { key: "6", exact: 6, label: "Silver Clasp in the refuse", references: [{ type: "item", id: "item.silver-clasp" }] },
+ ],
+ notes: ["Starter rubbish pile table using the codex family and page reference."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.sect1",
+ code: "SECT1",
+ name: "Secret Hatch Table 1",
+ category: "loot",
+ page: 29,
+ diceKind: "d6",
+ entries: [
+ { key: "1-3", min: 1, max: 3, label: "Hidden crawlspace, nothing useful" },
+ { key: "4-5", min: 4, max: 5, label: "Concealed purse", effects: [{ type: "gain-gold", amount: 2, target: "self" }] },
+ {
+ key: "6",
+ exact: 6,
+ label: "Concealed relic cache",
+ references: [{ type: "item", id: "item.silver-chalice" }],
+ },
+ ],
+ notes: ["Starter secret hatch table using the codex family and page reference."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.slt1",
+ code: "SLT1",
+ name: "Sarcophagus Loot Table 1",
+ category: "loot",
+ page: 30,
+ diceKind: "d6",
+ entries: [
+ { key: "1-2", min: 1, max: 2, label: "Dust and wrappings" },
+ { key: "3-4", min: 3, max: 4, label: "Funerary coins", effects: [{ type: "gain-gold", amount: 2, target: "self" }] },
+ { key: "5", exact: 5, label: "Silver Chalice", references: [{ type: "item", id: "item.silver-chalice" }] },
+ { key: "6", exact: 6, label: "Garnet Ring", references: [{ type: "item", id: "item.garnet-ring" }] },
+ ],
+ notes: ["Starter funerary loot table aligned to the codex sarcophagus family."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.tct1",
+ code: "TCT1",
+ name: "Tea Chest Table 1",
+ category: "loot",
+ page: 31,
+ diceKind: "d6",
+ entries: [
+ { key: "1-2", min: 1, max: 2, label: "Spoiled leaves and dust" },
+ { key: "3-4", min: 3, max: 4, label: "Wrapped ration", references: [{ type: "item", id: "item.ration" }] },
+ { key: "5", exact: 5, label: "Silver Clasp beneath the packing", references: [{ type: "item", id: "item.silver-clasp" }] },
+ {
+ key: "6",
+ exact: 6,
+ label: "Garnet Ring and coins",
+ references: [{ type: "item", id: "item.garnet-ring" }],
+ effects: [{ type: "gain-gold", amount: 2, target: "self" }],
+ },
+ ],
+ notes: ["Starter tea chest table using the codex family and page reference."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.url1",
+ code: "URL1",
+ name: "Urn Loot Table 1",
+ category: "loot",
+ page: 32,
+ diceKind: "d6",
+ entries: [
+ { key: "1-3", min: 1, max: 3, label: "Ash and fragments" },
+ { key: "4", exact: 4, label: "Coin offering", effects: [{ type: "gain-gold", amount: 1, target: "self" }] },
+ { key: "5", exact: 5, label: "Bone Charm", references: [{ type: "item", id: "item.bone-charm" }] },
+ { key: "6", exact: 6, label: "Silver Chalice in the ashes", references: [{ type: "item", id: "item.silver-chalice" }] },
+ ],
+ notes: ["Starter urn table using the codex family and page reference."],
+ mvp: true,
+ },
+ {
+ id: "table.codex.enp1",
+ code: "ENP1",
+ name: "Encounter Prisoner Table 1",
+ category: "level",
+ level: 1,
+ page: 34,
+ diceKind: "d6",
+ entries: [
+ { key: "1-3", min: 1, max: 3, label: "Empty cell" },
+ { key: "4-5", min: 4, max: 5, label: "Grateful prisoner", effects: [{ type: "gain-gold", amount: 2, target: "self" }] },
+ { key: "6", exact: 6, label: "Prisoner hid a keyring", references: [{ type: "item", id: "item.keeper-keyring" }] },
+ ],
+ notes: ["Starter prisoner interaction table using the codex family and page reference."],
+ mvp: true,
+ },
+ {
+ id: "table.level1.trap-check",
+ code: "L1TR",
+ name: "Level 1 Trap Check",
+ category: "level",
+ level: 1,
+ page: 118,
+ diceKind: "d6",
+ entries: [
+ { key: "1-3", min: 1, max: 3, label: "Minor scrape", effects: [{ type: "take-damage", amount: 1, target: "self" }] },
+ { key: "4-5", min: 4, max: 5, label: "Nasty jab", effects: [{ type: "take-damage", amount: 2, target: "self" }] },
+ { key: "6", exact: 6, label: "You avoid the danger", text: "The danger is detected before it triggers." },
+ ],
+ notes: ["Inferred placeholder until the exact level 1 trap flow is transcribed from the rules/codex."],
+ mvp: true,
+ },
+];
+
+const LEVEL1_ROOM_OBJECTS: Record = {
+ "room.level1.normal.abandoned-guard-post": [
+ {
+ objectType: "container",
+ title: "Guard Belt Pouch",
+ sourceTableCode: "PT1",
+ hidden: true,
+ searchable: true,
+ notes: "Searching the ruined post may uncover a dropped guard pouch.",
+ },
+ ],
+ "room.level1.normal.guard-post": [
+ {
+ objectType: "container",
+ title: "Guard Chest",
+ sourceTableCode: "CT1",
+ hidden: false,
+ searchable: true,
+ notes: "A service chest sits against the wall.",
+ },
+ ],
+ "room.level1.normal.meeting-room": [
+ {
+ objectType: "container",
+ title: "Meeting Table Pouch",
+ sourceTableCode: "PT1",
+ hidden: true,
+ searchable: true,
+ notes: "A dropped pouch may be tucked beneath the long table.",
+ },
+ ],
+ "room.level1.normal.blacksmith-space": [
+ {
+ objectType: "container",
+ title: "Smithing Chest",
+ sourceTableCode: "CT2",
+ hidden: false,
+ searchable: true,
+ notes: "A battered chest sits near the cold forge.",
+ },
+ ],
+ "room.level1.normal.storage-area": [
+ {
+ objectType: "container",
+ title: "Tea Chest",
+ sourceTableCode: "TCT1",
+ hidden: false,
+ searchable: true,
+ notes: "Spoiled goods and intact tea chests can be rummaged through.",
+ },
+ ],
+ "room.level1.normal.storage-area-2": [
+ {
+ objectType: "container",
+ title: "Supply Chest",
+ sourceTableCode: "CT2",
+ hidden: false,
+ searchable: true,
+ notes: "A chest sits among rope and folded cloth.",
+ },
+ ],
+ "room.level1.normal.holding-cell": [
+ {
+ objectType: "quest",
+ title: "Locked Cell",
+ sourceTableCode: "ENP1",
+ hidden: true,
+ searchable: true,
+ notes: "A careful look may reveal a prisoner or hidden goods.",
+ },
+ ],
+ "room.level1.normal.weapon-dump": [
+ {
+ objectType: "container",
+ title: "Rubbish Heap",
+ sourceTableCode: "RUPT1",
+ hidden: false,
+ searchable: true,
+ notes: "The heap may hide salvage beneath broken kit.",
+ },
+ ],
+ "room.level1.normal.guard-room": [
+ {
+ objectType: "container",
+ title: "Guard Supply Chest",
+ sourceTableCode: "CT1",
+ hidden: false,
+ searchable: true,
+ notes: "A guard chest sits behind the duty table.",
+ },
+ ],
+ "room.level1.normal.pool-room": [
+ {
+ objectType: "feature",
+ title: "Poolside Stone Hatch",
+ sourceTableCode: "SECT1",
+ hidden: true,
+ searchable: true,
+ notes: "The damp stonework may conceal a hatch near the basin.",
+ },
+ ],
+ "room.level1.normal.mourning-quarters": [
+ {
+ objectType: "corpse",
+ title: "Shrouded Bier",
+ sourceTableCode: "BST2",
+ hidden: false,
+ searchable: true,
+ notes: "The bier can be searched once the room is safe.",
+ },
+ {
+ objectType: "feature",
+ title: "Funerary Urn",
+ sourceTableCode: "URL1",
+ hidden: false,
+ searchable: true,
+ notes: "An urn sits beside the bier.",
+ },
+ ],
+ "room.level1.normal.holding-cell-2": [
+ {
+ objectType: "quest",
+ title: "Barred Cell",
+ sourceTableCode: "ENP1",
+ hidden: true,
+ searchable: true,
+ notes: "A careful search may reveal a prisoner or stash.",
+ },
+ ],
+ "room.level1.normal.chapel": [
+ {
+ objectType: "feature",
+ title: "Ash Urn",
+ sourceTableCode: "URL1",
+ hidden: false,
+ searchable: true,
+ notes: "A devotional urn sits near the central altar.",
+ },
+ ],
+ "room.level1.normal.canteen": [
+ {
+ objectType: "container",
+ title: "Canteen Refuse Heap",
+ sourceTableCode: "RUPT1",
+ hidden: false,
+ searchable: true,
+ notes: "Discarded scraps and crockery may hide something useful.",
+ },
+ ],
+ "room.level1.normal.training-room": [
+ {
+ objectType: "feature",
+ title: "Armour Rack",
+ sourceTableCode: "ART1",
+ hidden: false,
+ searchable: true,
+ notes: "Training gear and old armour still hang along the wall.",
+ },
+ ],
+ "room.level1.normal.wash-room": [
+ {
+ objectType: "container",
+ title: "Wash-Room Debris",
+ sourceTableCode: "RUPT1",
+ hidden: false,
+ searchable: true,
+ notes: "Rotting cloth and debris collect in one corner.",
+ },
+ ],
+ "room.level1.normal.kennel": [
+ {
+ objectType: "container",
+ title: "Straw Heap",
+ sourceTableCode: "RUPT1",
+ hidden: false,
+ searchable: true,
+ notes: "Something may be buried beneath the straw and filth.",
+ },
+ ],
+ "room.level1.normal.snake-pit": [
+ {
+ objectType: "container",
+ title: "Pitside Chest",
+ sourceTableCode: "CT1",
+ hidden: false,
+ searchable: true,
+ notes: "A chest can be opened once the snakes are dealt with.",
+ },
+ ],
+ "room.level1.large.stone-workshop": [
+ {
+ objectType: "container",
+ title: "Workshop Chest",
+ sourceTableCode: "CT1",
+ hidden: false,
+ searchable: true,
+ notes: "A heavy work chest stands near the benches.",
+ },
+ ],
+ "room.level1.large.marble-hall": [
+ {
+ objectType: "feature",
+ title: "Marble Reliquary",
+ sourceTableCode: "GOT1",
+ hidden: false,
+ searchable: true,
+ notes: "A carved reliquary rests on a pedestal in the hall.",
+ },
+ ],
+ "room.level1.large.old-mess-hall": [
+ {
+ objectType: "container",
+ title: "Refuse Pile",
+ sourceTableCode: "RUPT1",
+ hidden: false,
+ searchable: true,
+ notes: "Broken scraps and discarded remains lie beneath the benches.",
+ },
+ ],
+ "room.level1.large.penitentiary": [
+ {
+ objectType: "quest",
+ title: "Punishment Cell",
+ sourceTableCode: "ENP1",
+ hidden: false,
+ searchable: true,
+ notes: "A chained side cell may still hold a prisoner or hidden stash.",
+ },
+ ],
+ "room.level1.large.crate-store": [
+ {
+ objectType: "container",
+ title: "Tea Chest Stack",
+ sourceTableCode: "TCT1",
+ hidden: false,
+ searchable: true,
+ notes: "Several sturdy tea chests and crates can be opened.",
+ },
+ {
+ objectType: "feature",
+ title: "Hidden Floor Hatch",
+ sourceTableCode: "SECT1",
+ hidden: true,
+ searchable: true,
+ notes: "A thorough search may reveal a concealed hatch beneath the stacks.",
+ },
+ ],
+ "room.level1.large.slate-shrine": [
+ {
+ objectType: "feature",
+ title: "Slate Offering Urn",
+ sourceTableCode: "URL1",
+ hidden: false,
+ searchable: true,
+ notes: "Offerings and ash gather at the base of the monolith.",
+ },
+ ],
+ "room.level1.large.fountain-room": [
+ {
+ objectType: "feature",
+ title: "Fountain Offering Ring",
+ sourceTableCode: "MR1",
+ hidden: false,
+ searchable: true,
+ notes: "Offerings glitter beneath the surface of the fountain.",
+ },
+ ],
+ "room.level1.large.temple": [
+ {
+ objectType: "feature",
+ title: "Temple Amulet Reliquary",
+ sourceTableCode: "MA1",
+ hidden: false,
+ searchable: true,
+ notes: "A devotional reliquary may still hold a magical amulet.",
+ },
+ ],
+ "room.level1.large.dormitory": [
+ {
+ objectType: "container",
+ title: "Bunkside Pouch",
+ sourceTableCode: "PT2",
+ hidden: true,
+ searchable: true,
+ notes: "Searching the bunks may uncover a tucked-away pouch.",
+ },
+ ],
+ "room.level1.large.library": [
+ {
+ objectType: "feature",
+ title: "Scriptorium Scroll Tube",
+ sourceTableCode: "SCT1",
+ hidden: false,
+ searchable: true,
+ notes: "Scroll tubes and scraps of parchment lie among the shelves.",
+ },
+ ],
+ "room.level1.small.wall-shrine": [
+ {
+ objectType: "feature",
+ title: "Wall Urn",
+ sourceTableCode: "URL1",
+ hidden: false,
+ searchable: true,
+ notes: "A small offering urn rests in the niche.",
+ },
+ ],
+ "room.level1.small.heated-space": [
+ {
+ objectType: "feature",
+ title: "Loose Stone Hatch",
+ sourceTableCode: "SECT1",
+ hidden: true,
+ searchable: true,
+ notes: "The warmth suggests a concealed access point behind the wall.",
+ },
+ ],
+ "room.level1.small.banner-arms": [
+ {
+ objectType: "feature",
+ title: "Banner Arms Display",
+ sourceTableCode: "ART2",
+ hidden: false,
+ searchable: true,
+ notes: "Old banner arms and armour pieces are fixed to the wall.",
+ },
+ ],
+ "room.level1.small.strange-text": [
+ {
+ objectType: "feature",
+ title: "Inscribed Relic Shelf",
+ sourceTableCode: "GOT1",
+ hidden: false,
+ searchable: true,
+ notes: "The strange inscriptions surround a small relic shelf.",
+ },
+ ],
+ "room.level1.small.murtayne-effigy": [
+ {
+ objectType: "feature",
+ title: "Effigy Offering Niche",
+ sourceTableCode: "GOT1",
+ hidden: false,
+ searchable: true,
+ notes: "Offerings have gathered at the base of the effigy.",
+ },
+ ],
+};
+
+export function getLevel1RoomObjects(
+ roomTemplateId: string,
+): RoomObjectTemplate[] | undefined {
+ return LEVEL1_ROOM_OBJECTS[roomTemplateId]?.map((object) => ({ ...object }));
+}
diff --git a/src/data/sampleContentPack.ts b/src/data/sampleContentPack.ts
index 54858bb..08ae359 100644
--- a/src/data/sampleContentPack.ts
+++ b/src/data/sampleContentPack.ts
@@ -2,6 +2,10 @@ import type { ContentPack } from "@/types/content";
import { contentPackSchema } from "@/schemas/content";
import { level1RoomTemplates } from "./level1Rooms";
+import {
+ getLevel1RoomObjects,
+ level1RoomInteractionTables,
+} from "./level1RoomObjects";
import { level1EncounterTables } from "./level1Tables";
const samplePack = {
@@ -13,6 +17,7 @@ const samplePack = {
],
tables: [
...level1EncounterTables,
+ ...level1RoomInteractionTables,
{
id: "table.level1.humanoid-loot",
code: "L1HL",
@@ -264,6 +269,492 @@ const samplePack = {
valueGp: 12,
mvp: true,
},
+ {
+ id: "item.malako-leaves",
+ name: "Malako Leaves",
+ itemType: "herb",
+ stackable: true,
+ consumable: false,
+ valueGp: 2,
+ mvp: true,
+ },
+ {
+ id: "item.dankoma-stems",
+ name: "Dankoma Stems",
+ itemType: "herb",
+ stackable: true,
+ consumable: false,
+ valueGp: 2,
+ mvp: true,
+ },
+ {
+ id: "item.redroot-spines",
+ name: "Redroot Spines",
+ itemType: "herb",
+ stackable: true,
+ consumable: false,
+ valueGp: 2,
+ mvp: true,
+ },
+ {
+ id: "item.wolf-worm-eggs",
+ name: "Wolf Worm Eggs",
+ itemType: "herb",
+ stackable: true,
+ consumable: false,
+ valueGp: 2,
+ mvp: true,
+ },
+ {
+ id: "item.scarlet-ore-leaves",
+ name: "Scarlet Ore Leaves",
+ itemType: "herb",
+ stackable: true,
+ consumable: false,
+ valueGp: 2,
+ mvp: true,
+ },
+ {
+ id: "item.oretauts-leaves",
+ name: "Oretauts Leaves",
+ itemType: "herb",
+ stackable: true,
+ consumable: false,
+ valueGp: 2,
+ mvp: true,
+ },
+ {
+ id: "item.pearl",
+ name: "Pearl",
+ itemType: "treasure",
+ stackable: true,
+ consumable: false,
+ valueGp: 5,
+ mvp: true,
+ },
+ {
+ id: "item.sapphire",
+ name: "Sapphire",
+ itemType: "treasure",
+ stackable: true,
+ consumable: false,
+ valueGp: 10,
+ mvp: true,
+ },
+ {
+ id: "item.garnet",
+ name: "Garnet",
+ itemType: "treasure",
+ stackable: true,
+ consumable: false,
+ valueGp: 8,
+ mvp: true,
+ },
+ {
+ id: "item.ruby",
+ name: "Ruby",
+ itemType: "treasure",
+ stackable: true,
+ consumable: false,
+ valueGp: 12,
+ mvp: true,
+ },
+ {
+ id: "item.emerald",
+ name: "Emerald",
+ itemType: "treasure",
+ stackable: true,
+ consumable: false,
+ valueGp: 12,
+ mvp: true,
+ },
+ {
+ id: "item.diamond",
+ name: "Diamond",
+ itemType: "treasure",
+ stackable: true,
+ consumable: false,
+ valueGp: 20,
+ mvp: true,
+ },
+ {
+ id: "item.half-copper-pendant",
+ name: "Half a Copper Pendant",
+ itemType: "treasure",
+ stackable: false,
+ consumable: false,
+ valueGp: 5,
+ mvp: true,
+ },
+ {
+ id: "item.half-gold-pendant",
+ name: "Half a Gold Pendant",
+ itemType: "treasure",
+ stackable: false,
+ consumable: false,
+ valueGp: 5,
+ mvp: true,
+ },
+ {
+ id: "item.half-gold-cross",
+ name: "Half a Gold Cross",
+ itemType: "treasure",
+ stackable: false,
+ consumable: false,
+ valueGp: 20,
+ mvp: true,
+ },
+ {
+ id: "item.half-silver-cross",
+ name: "Half a Silver Cross",
+ itemType: "treasure",
+ stackable: false,
+ consumable: false,
+ valueGp: 3,
+ mvp: true,
+ },
+ {
+ id: "item.half-gold-symbol",
+ name: "Half a Gold Symbol",
+ itemType: "treasure",
+ stackable: false,
+ consumable: false,
+ valueGp: 15,
+ mvp: true,
+ },
+ {
+ id: "item.half-gold-symbol-high",
+ name: "Half a Gold Symbol",
+ itemType: "treasure",
+ stackable: false,
+ consumable: false,
+ valueGp: 40,
+ mvp: true,
+ },
+ {
+ id: "item.god-ornate-goada",
+ name: "Goada the Helm",
+ itemType: "treasure",
+ stackable: false,
+ consumable: false,
+ valueGp: 18,
+ mvp: true,
+ },
+ {
+ id: "item.god-ornate-intuneric",
+ name: "Intuneric the Murk",
+ itemType: "treasure",
+ stackable: false,
+ consumable: false,
+ valueGp: 18,
+ mvp: true,
+ },
+ {
+ id: "item.god-ornate-murtayne",
+ name: "Murtayne the Pup",
+ itemType: "treasure",
+ stackable: false,
+ consumable: false,
+ valueGp: 18,
+ mvp: true,
+ },
+ {
+ id: "item.god-ornate-nevzator",
+ name: "Nevzator the Blind",
+ itemType: "treasure",
+ stackable: false,
+ consumable: false,
+ valueGp: 18,
+ mvp: true,
+ },
+ {
+ id: "item.god-ornate-radacina",
+ name: "Radacina the X",
+ itemType: "treasure",
+ stackable: false,
+ consumable: false,
+ valueGp: 18,
+ mvp: true,
+ },
+ {
+ id: "item.god-ornate-madi",
+ name: "Madi the Sphere",
+ itemType: "treasure",
+ stackable: false,
+ consumable: false,
+ valueGp: 18,
+ mvp: true,
+ },
+ {
+ id: "item.ring-encountered",
+ name: "Encountered Ring",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 20,
+ rulesText: "Magic ring from MR1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.ring-of-baseness",
+ name: "Ring of Baseness",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 20,
+ rulesText: "Magic ring from MR1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.ring-of-spells",
+ name: "Ring of Spells",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 25,
+ rulesText: "Magic ring from MR1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.ring-of-steadiness",
+ name: "Ring of Steadiness",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 25,
+ rulesText: "Magic ring from MR1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.ring-of-transformation",
+ name: "Ring of Transformation",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 30,
+ rulesText: "Magic ring from MR1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.ring-of-leaving",
+ name: "Ring of Leaving",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 30,
+ rulesText: "Magic ring from MR1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.amulet-of-resistance",
+ name: "Amulet of Resistance",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 30,
+ rulesText: "Magic amulet from MA1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.amulet-of-fire-resistance",
+ name: "Amulet of Fire Resistance",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 35,
+ rulesText: "Magic amulet from MA1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.amulet-of-ice-resistance",
+ name: "Amulet of Ice Resistance",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 35,
+ rulesText: "Magic amulet from MA1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.amulet-of-poison-resistance",
+ name: "Amulet of Poison Resistance",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 35,
+ rulesText: "Magic amulet from MA1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.wand-of-fireballs",
+ name: "Wand of Fireballs",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 50,
+ rulesText: "Magic wand from MW1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.wand-of-fire",
+ name: "Wand of Fire",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 40,
+ rulesText: "Magic wand from MW1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.wand-of-sunder",
+ name: "Wand of Sunder",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 45,
+ rulesText: "Magic wand from MW1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.wand-of-sleep",
+ name: "Wand of Sleep",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 40,
+ rulesText: "Magic wand from MW1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.wand-of-paralysis",
+ name: "Wand of Paralysis",
+ itemType: "misc",
+ stackable: false,
+ consumable: false,
+ valueGp: 45,
+ rulesText: "Magic wand from MW1; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.potion-of-swamp-lung",
+ name: "Potion of Swamp Lung",
+ itemType: "misc",
+ stackable: false,
+ consumable: true,
+ valueGp: 10,
+ rulesText: "Codex potion result; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.potion-of-aura",
+ name: "Potion of Aura",
+ itemType: "misc",
+ stackable: false,
+ consumable: true,
+ valueGp: 15,
+ rulesText: "Codex potion result; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.potion-of-insightful-combat",
+ name: "Potion of Insightful Combat",
+ itemType: "misc",
+ stackable: false,
+ consumable: true,
+ valueGp: 40,
+ rulesText: "Codex potion result; effect automation pending.",
+ mvp: true,
+ },
+ {
+ id: "item.padded-tunic",
+ name: "Padded Tunic",
+ itemType: "gear",
+ stackable: false,
+ consumable: false,
+ valueGp: 5,
+ mvp: true,
+ },
+ {
+ id: "item.scale-jacket",
+ name: "Scale Jacket",
+ itemType: "gear",
+ stackable: false,
+ consumable: false,
+ valueGp: 12,
+ mvp: true,
+ },
+ {
+ id: "item.hide-doublet",
+ name: "Hide Doublet",
+ itemType: "gear",
+ stackable: false,
+ consumable: false,
+ valueGp: 9,
+ mvp: true,
+ },
+ {
+ id: "item.bishops-mail",
+ name: "Bishops Mail",
+ itemType: "gear",
+ stackable: false,
+ consumable: false,
+ valueGp: 16,
+ mvp: true,
+ },
+ {
+ id: "item.morning-jacket",
+ name: "Morning Jacket",
+ itemType: "gear",
+ stackable: false,
+ consumable: false,
+ valueGp: 7,
+ mvp: true,
+ },
+ {
+ id: "item.leather-breastplate",
+ name: "Leather Breastplate",
+ itemType: "gear",
+ stackable: false,
+ consumable: false,
+ valueGp: 11,
+ mvp: true,
+ },
+ {
+ id: "item.leather-bracers",
+ name: "Leather Bracers",
+ itemType: "gear",
+ stackable: false,
+ consumable: false,
+ valueGp: 8,
+ mvp: true,
+ },
+ {
+ id: "item.brigandine-coat",
+ name: "Brigandine Coat",
+ itemType: "gear",
+ stackable: false,
+ consumable: false,
+ valueGp: 14,
+ mvp: true,
+ },
+ {
+ id: "item.hide-doublet-alt",
+ name: "Hide Doublet",
+ itemType: "gear",
+ stackable: false,
+ consumable: false,
+ valueGp: 9,
+ mvp: true,
+ },
+ {
+ id: "item.woden-shield",
+ name: "Woden Shield",
+ itemType: "gear",
+ stackable: false,
+ consumable: false,
+ valueGp: 10,
+ mvp: true,
+ },
],
potions: [
{
@@ -274,6 +765,27 @@ const samplePack = {
effects: [{ type: "heal", amount: 3, target: "self" }],
mvp: true,
},
+ { id: "potion.no-healing", name: "Potion of No Healing", tableSource: "POT1", useTiming: "any", effects: [], valueGp: 5, mvp: true },
+ { id: "potion.healing-alt", name: "Potion of Healing", tableSource: "POT1", useTiming: "any", effects: [{ type: "heal", amount: 3, target: "self" }], valueGp: 25, mvp: true },
+ { id: "potion.examination", name: "Potion of Examination", tableSource: "POT1", useTiming: "exploration", effects: [], valueGp: 5, mvp: true },
+ { id: "potion.strength", name: "Potion of Strength", tableSource: "POT1", useTiming: "combat", effects: [], valueGp: 15, mvp: true },
+ { id: "potion.extra-healing", name: "Potion of Extra Healing", tableSource: "POT2", useTiming: "any", effects: [{ type: "heal", amount: 5, target: "self" }], valueGp: 40, mvp: true },
+ { id: "potion.prowess", name: "Potion of Prowess", tableSource: "POT2", useTiming: "combat", effects: [], valueGp: 26, mvp: true },
+ { id: "potion.mighty-strength", name: "Potion of Mighty Strength", tableSource: "POT2", useTiming: "combat", effects: [], valueGp: 20, mvp: true },
+ { id: "potion.gain-health", name: "Potion of Gain Health", tableSource: "POT2", useTiming: "any", effects: [{ type: "heal", amount: 15, target: "self" }], valueGp: 25, mvp: true },
+ { id: "potion.finesse", name: "Potion of Finesse", tableSource: "POT2", useTiming: "combat", effects: [], valueGp: 50, mvp: true },
+ { id: "potion.finesse-alt", name: "Potion of Finesse", tableSource: "POT2", useTiming: "combat", effects: [], valueGp: 50, mvp: true },
+ { id: "potion.finesse-3", name: "Potion of Finesse", tableSource: "POT3", useTiming: "combat", effects: [], valueGp: 50, mvp: true },
+ { id: "potion.gain-health-alt", name: "Potion of Gain Health", tableSource: "POT3", useTiming: "any", effects: [{ type: "heal", amount: 15, target: "self" }], valueGp: 25, mvp: true },
+ { id: "potion.gain-health-2", name: "Potion of Gain Health", tableSource: "POT3", useTiming: "any", effects: [{ type: "heal", amount: 15, target: "self" }], valueGp: 25, mvp: true },
+ { id: "potion.divine-shield", name: "Potion of Divine Shield", tableSource: "POT3", useTiming: "combat", effects: [], valueGp: 1000, mvp: true },
+ { id: "potion.willpower", name: "Potion of Willpower", tableSource: "POT3", useTiming: "exploration", effects: [], valueGp: 30, mvp: true },
+ { id: "potion.further-healing", name: "Further Healing", tableSource: "POT4", useTiming: "any", effects: [{ type: "heal", amount: 25, target: "self" }], valueGp: 40, mvp: true },
+ { id: "potion.healing-4", name: "Potion of Healing", tableSource: "POT4", useTiming: "any", effects: [{ type: "heal", amount: 3, target: "self" }], valueGp: 25, mvp: true },
+ { id: "potion.steadiness", name: "Potion of Steadiness", tableSource: "POT4", useTiming: "combat", effects: [], valueGp: 8, mvp: true },
+ { id: "potion.domination", name: "Potion of Domination", tableSource: "POT4", useTiming: "combat", effects: [], valueGp: 200, mvp: true },
+ { id: "potion.dexterous-actions", name: "Potion of Dexterous Actions", tableSource: "POT4", useTiming: "combat", effects: [], valueGp: 100, mvp: true },
+ { id: "potion.power-of-invisibility", name: "Power of Invisibility", tableSource: "BST2", useTiming: "any", effects: [], valueGp: 80, mvp: true },
],
scrolls: [
{
@@ -289,6 +801,12 @@ const samplePack = {
startingOption: true,
mvp: true,
},
+ { id: "scroll.balance", name: "Scroll of Balance", tableSource: "SCT1", onSuccess: [], startingOption: false, valueGp: 20, mvp: true },
+ { id: "scroll.reading", name: "Scroll of Reading", tableSource: "SCT1", onSuccess: [], startingOption: false, valueGp: 15, mvp: true },
+ { id: "scroll.brute-force", name: "Scroll of Brute Force", tableSource: "SCT1", onSuccess: [], startingOption: false, valueGp: 20, mvp: true },
+ { id: "scroll.ignite", name: "Scroll of Ignite", tableSource: "SCT1", onSuccess: [], startingOption: false, valueGp: 15, mvp: true },
+ { id: "scroll.mental-whip", name: "Scroll of Mental Whip", tableSource: "SCT1", onSuccess: [], startingOption: false, valueGp: 20, mvp: true },
+ { id: "scroll.paralysis", name: "Scroll of Paralysis", tableSource: "SCT1", onSuccess: [], startingOption: false, valueGp: 25, mvp: true },
],
creatures: [
{
@@ -419,7 +937,10 @@ const samplePack = {
tags: ["starter", "entry"],
mvp: true,
},
- ...level1RoomTemplates,
+ ...level1RoomTemplates.map((template) => ({
+ ...template,
+ objects: getLevel1RoomObjects(template.id),
+ })),
],
townServices: [
{
diff --git a/src/rules/character.ts b/src/rules/character.ts
index fff3dca..5909005 100644
--- a/src/rules/character.ts
+++ b/src/rules/character.ts
@@ -123,6 +123,7 @@ export function createStartingAdventurer(
stored: [],
currency: {
gold: 0,
+ silver: 0,
},
rationCount: 3,
lightSources: [makeInventoryEntry("item.lantern")],
diff --git a/src/rules/combatTurns.ts b/src/rules/combatTurns.ts
index 953499c..781da86 100644
--- a/src/rules/combatTurns.ts
+++ b/src/rules/combatTurns.ts
@@ -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],
),
diff --git a/src/rules/magicItems.ts b/src/rules/magicItems.ts
new file mode 100644
index 0000000..5f9855b
--- /dev/null
+++ b/src/rules/magicItems.ts
@@ -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;
+}
diff --git a/src/rules/roomObjects.test.ts b/src/rules/roomObjects.test.ts
new file mode 100644
index 0000000..eb8156a
--- /dev/null
+++ b/src/rules/roomObjects.test.ts
@@ -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");
+ });
+});
diff --git a/src/rules/roomObjects.ts b/src/rules/roomObjects.ts
new file mode 100644
index 0000000..1c881a7
--- /dev/null
+++ b/src/rules/roomObjects.ts
@@ -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,
+ ) => {
+ 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,
+ };
+}
diff --git a/src/rules/rooms.ts b/src/rules/rooms.ts
index d0d4640..7d505e6 100644
--- a/src/rules/rooms.ts
+++ b/src/rules/rooms.ts
@@ -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),
),
diff --git a/src/rules/runState.test.ts b/src/rules/runState.test.ts
index 095a682..e78f474 100644
--- a/src/rules/runState.test.ts
+++ b/src/rules/runState.test.ts
@@ -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,
diff --git a/src/rules/runState.ts b/src/rules/runState.ts
index 07aa9fd..c717428 100644
--- a/src/rules/runState.ts
+++ b/src/rules/runState.ts
@@ -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}.`);
+ }
+}
diff --git a/src/schemas/content.ts b/src/schemas/content.ts
index 8823556..6566469 100644
--- a/src/schemas/content.ts
+++ b/src/schemas/content.ts
@@ -157,6 +157,15 @@ export const exitTemplateSchema = z.object({
destinationLevel: z.number().int().optional(),
});
+export const roomObjectTemplateSchema = z.object({
+ objectType: z.enum(["container", "altar", "corpse", "hazard", "feature", "quest"]),
+ title: z.string().min(1),
+ sourceTableCode: z.string().min(1).optional(),
+ hidden: z.boolean().optional(),
+ searchable: z.boolean().optional(),
+ notes: z.string().optional(),
+});
+
export const roomTemplateSchema = z.object({
id: z.string().min(1),
level: z.number().int().positive(),
@@ -176,6 +185,7 @@ export const roomTemplateSchema = z.object({
})
.optional(),
exits: z.array(exitTemplateSchema).optional(),
+ objects: z.array(roomObjectTemplateSchema).optional(),
encounterRefs: z.array(contentReferenceSchema).optional(),
objectRefs: z.array(contentReferenceSchema).optional(),
tags: z.array(z.string()),
diff --git a/src/schemas/rules.ts b/src/schemas/rules.ts
index b9cabdc..27241a2 100644
--- a/src/schemas/rules.ts
+++ b/src/schemas/rules.ts
@@ -17,12 +17,14 @@ export const contentReferenceTypeSchema = z.enum([
export const contentReferenceSchema = z.object({
type: contentReferenceTypeSchema,
id: z.string().min(1),
+ quantity: z.number().int().positive().optional(),
});
export const ruleEffectSchema = z.object({
type: z.enum([
"gain-xp",
"gain-gold",
+ "gain-silver",
"heal",
"take-damage",
"modify-shift",
@@ -38,6 +40,8 @@ export const ruleEffectSchema = z.object({
"log-only",
]),
amount: z.number().optional(),
+ diceKind: diceKindSchema.optional(),
+ rollCount: z.number().int().positive().optional(),
statusId: z.string().optional(),
target: z.enum(["self", "enemy", "room", "campaign"]).optional(),
referenceId: z.string().optional(),
diff --git a/src/schemas/state.ts b/src/schemas/state.ts
index fbb1521..e7f4340 100644
--- a/src/schemas/state.ts
+++ b/src/schemas/state.ts
@@ -24,6 +24,7 @@ export const inventoryStateSchema = z.object({
stored: z.array(inventoryEntrySchema),
currency: z.object({
gold: z.number().int().nonnegative(),
+ silver: z.number().int().nonnegative(),
}),
rationCount: z.number().int().nonnegative(),
lightSources: z.array(inventoryEntrySchema),
@@ -120,9 +121,17 @@ export const encounterStateSchema = z.object({
export const roomObjectStateSchema = z.object({
id: z.string().min(1),
objectType: z.enum(["container", "altar", "corpse", "hazard", "feature", "quest"]),
+ title: z.string().min(1),
sourceTableCode: z.string().optional(),
interacted: z.boolean(),
+ resolved: z.boolean().optional(),
hidden: z.boolean().optional(),
+ searchable: z.boolean().optional(),
+ rewardItemId: z.string().optional(),
+ rewardGold: z.number().int().nonnegative().optional(),
+ damage: z.number().int().nonnegative().optional(),
+ resolutionLabel: z.string().optional(),
+ resolutionEntryKey: z.string().optional(),
effects: z.array(ruleEffectSchema).optional(),
notes: z.string().optional(),
});
@@ -239,6 +248,7 @@ export const runStateSchema = z.object({
defeatedCreatureIds: z.array(z.string()),
xpGained: z.number().int().nonnegative(),
goldGained: z.number().int().nonnegative(),
+ silverGained: z.number().int().nonnegative(),
lootedItems: z.array(inventoryEntrySchema),
log: z.array(logEntrySchema),
pendingEffects: z.array(ruleEffectSchema),
diff --git a/src/types/content.ts b/src/types/content.ts
index 691fa56..74b754b 100644
--- a/src/types/content.ts
+++ b/src/types/content.ts
@@ -155,12 +155,29 @@ export type CreatureDefinition = {
export type ExitType = "open" | "door" | "locked" | "secret" | "shaft" | "stairs";
+export type RoomObjectType =
+ | "container"
+ | "altar"
+ | "corpse"
+ | "hazard"
+ | "feature"
+ | "quest";
+
export type ExitTemplate = {
direction?: "north" | "east" | "south" | "west";
exitType: ExitType;
destinationLevel?: number;
};
+export type RoomObjectTemplate = {
+ objectType: RoomObjectType;
+ title: string;
+ sourceTableCode?: string;
+ hidden?: boolean;
+ searchable?: boolean;
+ notes?: string;
+};
+
export type RoomClass = "normal" | "small" | "large" | "special" | "start" | "stairs";
export type RoomTemplate = {
@@ -180,6 +197,7 @@ export type RoomTemplate = {
height: number;
};
exits?: ExitTemplate[];
+ objects?: RoomObjectTemplate[];
encounterRefs?: ContentReference[];
objectRefs?: ContentReference[];
tags: string[];
diff --git a/src/types/rules.ts b/src/types/rules.ts
index 34d73c6..ee20099 100644
--- a/src/types/rules.ts
+++ b/src/types/rules.ts
@@ -15,11 +15,13 @@ export type ContentReferenceType =
export type ContentReference = {
type: ContentReferenceType;
id: string;
+ quantity?: number;
};
export type RuleEffectType =
| "gain-xp"
| "gain-gold"
+ | "gain-silver"
| "heal"
| "take-damage"
| "modify-shift"
@@ -39,6 +41,8 @@ export type RuleEffectTarget = "self" | "enemy" | "room" | "campaign";
export type RuleEffect = {
type: RuleEffectType;
amount?: number;
+ diceKind?: DiceKind;
+ rollCount?: number;
statusId?: string;
target?: RuleEffectTarget;
referenceId?: string;
diff --git a/src/types/state.ts b/src/types/state.ts
index 4b1553c..33d1604 100644
--- a/src/types/state.ts
+++ b/src/types/state.ts
@@ -25,6 +25,7 @@ export type InventoryState = {
stored: InventoryEntry[];
currency: {
gold: number;
+ silver: number;
};
rationCount: number;
lightSources: InventoryEntry[];
@@ -121,9 +122,17 @@ export type EncounterState = {
export type RoomObjectState = {
id: string;
objectType: "container" | "altar" | "corpse" | "hazard" | "feature" | "quest";
+ title: string;
sourceTableCode?: string;
interacted: boolean;
+ resolved?: boolean;
hidden?: boolean;
+ searchable?: boolean;
+ rewardItemId?: string;
+ rewardGold?: number;
+ damage?: number;
+ resolutionLabel?: string;
+ resolutionEntryKey?: string;
effects?: RuleEffect[];
notes?: string;
};
@@ -240,6 +249,7 @@ export type RunState = {
defeatedCreatureIds: string[];
xpGained: number;
goldGained: number;
+ silverGained: number;
lootedItems: InventoryEntry[];
log: LogEntry[];
pendingEffects: RuleEffect[];