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} +

+ +
+
+ Relic + Ring of Spells +

+ Release a stored charm to restore 2 HP. Carried: {magicItemCounts.ringOfSpells} +

+ +
+
+ Relic + Amulet of Resistance +

+ Reduce the next damage taken by 1. Carried: {magicItemCounts.amuletOfResistance} +

+ +
+
+ Relic + Amulet of Fire Resistance +

+ Reduce the next damage taken by 2. Carried: {magicItemCounts.amuletOfFireResistance} +

+ +
+
+ Wand + Wand of Fire +

+ Scorch the first living enemy for 2 damage. Carried: {magicItemCounts.wandOfFire} +

+ +
+
+ Wand + Wand of Sleep +

+ Put the first living enemy to sleep for its next turn. Carried: {magicItemCounts.wandOfSleep} +

+ +
+
+ Potion + Potion of Aura +

+ Reveal hidden room objects while exploring. Carried: {magicItemCounts.potionOfAura} +

+ +
+
+ Potion + Insightful Combat +

+ Gain +1 precision on the next attack. Carried: {magicItemCounts.insightfulCombat} +

+ +
@@ -665,6 +842,10 @@ function App() { Current Gold {run.adventurerSnapshot.inventory.currency.gold} +
+ Current Silver + {run.adventurerSnapshot.inventory.currency.silver} +
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}
+
+
+
+

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."} +

+ +
+ )) + )} +
{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[];