Expand room generation fidelity and magic item actions
This commit is contained in:
@@ -4,3 +4,4 @@ dist/
|
|||||||
vite.config.js
|
vite.config.js
|
||||||
vite.config.d.ts
|
vite.config.d.ts
|
||||||
Notes/rendered-pages/
|
Notes/rendered-pages/
|
||||||
|
Notes/_codex_tables/
|
||||||
|
|||||||
+222
-2
@@ -20,14 +20,24 @@ import {
|
|||||||
enterCurrentRoom,
|
enterCurrentRoom,
|
||||||
getAvailableMoves,
|
getAvailableMoves,
|
||||||
isCurrentRoomCombatReady,
|
isCurrentRoomCombatReady,
|
||||||
|
resolveCurrentRoomObject,
|
||||||
resolveRunEnemyTurn,
|
resolveRunEnemyTurn,
|
||||||
resolveRunPlayerTurn,
|
resolveRunPlayerTurn,
|
||||||
resumeDungeon,
|
resumeDungeon,
|
||||||
returnToTown,
|
returnToTown,
|
||||||
|
searchCurrentRoom,
|
||||||
startCombatInCurrentRoom,
|
startCombatInCurrentRoom,
|
||||||
travelCurrentExit,
|
travelCurrentExit,
|
||||||
|
useRunMagicItem,
|
||||||
} from "@/rules/runState";
|
} from "@/rules/runState";
|
||||||
import { getNextLevelXpThreshold, MAX_ADVENTURER_LEVEL } from "@/rules/progression";
|
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 {
|
import {
|
||||||
getConsumableCounts,
|
getConsumableCounts,
|
||||||
restWithRation,
|
restWithRation,
|
||||||
@@ -91,11 +101,24 @@ function getTownServiceDescription(serviceId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getItemName(definitionId: 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) {
|
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) {
|
function getManoeuvreName(manoeuvreId: string) {
|
||||||
@@ -130,6 +153,23 @@ function App() {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const consumableCounts = getConsumableCounts(run);
|
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 latestCombatLogs = run.activeCombat?.combatLog.slice(-3).reverse() ?? [];
|
||||||
const nextLevelXpThreshold =
|
const nextLevelXpThreshold =
|
||||||
run.adventurerSnapshot.level >= MAX_ADVENTURER_LEVEL
|
run.adventurerSnapshot.level >= MAX_ADVENTURER_LEVEL
|
||||||
@@ -226,6 +266,20 @@ function App() {
|
|||||||
updateRun((previous) => returnToTown(previous).run);
|
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 = () => {
|
const handleCompleteLevel = () => {
|
||||||
updateRun((previous) => completeCurrentLevel(previous).run);
|
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 handleSaveCampaign = () => {
|
||||||
const storage = getBrowserStorage();
|
const storage = getBrowserStorage();
|
||||||
|
|
||||||
@@ -608,6 +673,118 @@ function App() {
|
|||||||
Eat And Rest
|
Eat And Rest
|
||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
|
<article className="recovery-card">
|
||||||
|
<span className="encounter-label">Relic</span>
|
||||||
|
<strong>Ring of Leaving</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Escape straight back to town from the dungeon. Carried: {magicItemCounts.ringOfLeaving}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleUseMagicItem("item.ring-of-leaving")}
|
||||||
|
disabled={inTown || Boolean(run.activeCombat) || magicItemCounts.ringOfLeaving === 0}
|
||||||
|
>
|
||||||
|
Invoke Ring
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
<article className="recovery-card">
|
||||||
|
<span className="encounter-label">Relic</span>
|
||||||
|
<strong>Ring of Spells</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Release a stored charm to restore 2 HP. Carried: {magicItemCounts.ringOfSpells}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleUseMagicItem("item.ring-of-spells")}
|
||||||
|
disabled={magicItemCounts.ringOfSpells === 0}
|
||||||
|
>
|
||||||
|
Invoke Ring
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
<article className="recovery-card">
|
||||||
|
<span className="encounter-label">Relic</span>
|
||||||
|
<strong>Amulet of Resistance</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Reduce the next damage taken by 1. Carried: {magicItemCounts.amuletOfResistance}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleUseMagicItem("item.amulet-of-resistance")}
|
||||||
|
disabled={inTown || magicItemCounts.amuletOfResistance === 0 || magicStatuses.resistance}
|
||||||
|
>
|
||||||
|
Raise Ward
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
<article className="recovery-card">
|
||||||
|
<span className="encounter-label">Relic</span>
|
||||||
|
<strong>Amulet of Fire Resistance</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Reduce the next damage taken by 2. Carried: {magicItemCounts.amuletOfFireResistance}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleUseMagicItem("item.amulet-of-fire-resistance")}
|
||||||
|
disabled={inTown || magicItemCounts.amuletOfFireResistance === 0 || magicStatuses.fireResistance}
|
||||||
|
>
|
||||||
|
Raise Fire Ward
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
<article className="recovery-card">
|
||||||
|
<span className="encounter-label">Wand</span>
|
||||||
|
<strong>Wand of Fire</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Scorch the first living enemy for 2 damage. Carried: {magicItemCounts.wandOfFire}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleUseMagicItem("item.wand-of-fire")}
|
||||||
|
disabled={!run.activeCombat || run.activeCombat.actingSide !== "player" || magicItemCounts.wandOfFire === 0}
|
||||||
|
>
|
||||||
|
Cast Fire
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
<article className="recovery-card">
|
||||||
|
<span className="encounter-label">Wand</span>
|
||||||
|
<strong>Wand of Sleep</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Put the first living enemy to sleep for its next turn. Carried: {magicItemCounts.wandOfSleep}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleUseMagicItem("item.wand-of-sleep")}
|
||||||
|
disabled={!run.activeCombat || run.activeCombat.actingSide !== "player" || magicItemCounts.wandOfSleep === 0}
|
||||||
|
>
|
||||||
|
Cast Sleep
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
<article className="recovery-card">
|
||||||
|
<span className="encounter-label">Potion</span>
|
||||||
|
<strong>Potion of Aura</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Reveal hidden room objects while exploring. Carried: {magicItemCounts.potionOfAura}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleUseMagicItem("item.potion-of-aura")}
|
||||||
|
disabled={inTown || Boolean(run.activeCombat) || magicItemCounts.potionOfAura === 0}
|
||||||
|
>
|
||||||
|
Drink Aura
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
<article className="recovery-card">
|
||||||
|
<span className="encounter-label">Potion</span>
|
||||||
|
<strong>Insightful Combat</strong>
|
||||||
|
<p className="supporting-text">
|
||||||
|
Gain +1 precision on the next attack. Carried: {magicItemCounts.insightfulCombat}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleUseMagicItem("item.potion-of-insightful-combat")}
|
||||||
|
disabled={!run.activeCombat || run.activeCombat.actingSide !== "player" || magicItemCounts.insightfulCombat === 0 || magicStatuses.insight}
|
||||||
|
>
|
||||||
|
Drink Combat Draft
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -665,6 +842,10 @@ function App() {
|
|||||||
<span className="encounter-label">Current Gold</span>
|
<span className="encounter-label">Current Gold</span>
|
||||||
<strong>{run.adventurerSnapshot.inventory.currency.gold}</strong>
|
<strong>{run.adventurerSnapshot.inventory.currency.gold}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="encounter-box">
|
||||||
|
<span className="encounter-label">Current Silver</span>
|
||||||
|
<strong>{run.adventurerSnapshot.inventory.currency.silver}</strong>
|
||||||
|
</div>
|
||||||
<div className="encounter-box">
|
<div className="encounter-box">
|
||||||
<span className="encounter-label">Rooms Found</span>
|
<span className="encounter-label">Rooms Found</span>
|
||||||
<strong>{currentLevel?.discoveredRoomOrder.length ?? 0}</strong>
|
<strong>{currentLevel?.discoveredRoomOrder.length ?? 0}</strong>
|
||||||
@@ -821,12 +1002,20 @@ function App() {
|
|||||||
<div className="room-meta">
|
<div className="room-meta">
|
||||||
<span>Entered: {currentRoom?.discovery.entered ? "Yes" : "No"}</span>
|
<span>Entered: {currentRoom?.discovery.entered ? "Yes" : "No"}</span>
|
||||||
<span>Cleared: {currentRoom?.discovery.cleared ? "Yes" : "No"}</span>
|
<span>Cleared: {currentRoom?.discovery.cleared ? "Yes" : "No"}</span>
|
||||||
|
<span>Searched: {currentRoom?.discovery.searched ? "Yes" : "No"}</span>
|
||||||
<span>Exits: {currentRoom?.exits.length ?? 0}</span>
|
<span>Exits: {currentRoom?.exits.length ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
<button className="button" onClick={handleEnterRoom}>
|
<button className="button" onClick={handleEnterRoom}>
|
||||||
Enter Room
|
Enter Room
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={handleSearchRoom}
|
||||||
|
disabled={!currentRoom || Boolean(run.activeCombat)}
|
||||||
|
>
|
||||||
|
Search Room
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="button button-primary"
|
className="button button-primary"
|
||||||
onClick={handleStartCombat}
|
onClick={handleStartCombat}
|
||||||
@@ -846,6 +1035,37 @@ function App() {
|
|||||||
<span className="encounter-label">Encounter</span>
|
<span className="encounter-label">Encounter</span>
|
||||||
<strong>{currentRoom?.encounter?.resultLabel ?? "None"}</strong>
|
<strong>{currentRoom?.encounter?.resultLabel ?? "None"}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="combat-feed">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>Room Objects</h2>
|
||||||
|
<span>{currentRoom?.objects.filter((object) => !object.hidden).length ?? 0} visible</span>
|
||||||
|
</div>
|
||||||
|
{!currentRoom || currentRoom.objects.filter((object) => !object.hidden).length === 0 ? (
|
||||||
|
<p className="supporting-text">No discovered objects in this room yet.</p>
|
||||||
|
) : (
|
||||||
|
currentRoom.objects
|
||||||
|
.filter((object) => !object.hidden)
|
||||||
|
.map((object) => (
|
||||||
|
<article key={object.id} className="enemy-card">
|
||||||
|
<span>{object.objectType}</span>
|
||||||
|
<strong>{object.title}</strong>
|
||||||
|
{object.sourceTableCode ? <em>{object.sourceTableCode}</em> : null}
|
||||||
|
<p className="supporting-text">
|
||||||
|
{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."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => handleResolveRoomObject(object.id)}
|
||||||
|
disabled={object.interacted || Boolean(run.activeCombat)}
|
||||||
|
>
|
||||||
|
{object.interacted ? "Resolved" : "Resolve Object"}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{levelCompletionReady ? (
|
{levelCompletionReady ? (
|
||||||
<p className="supporting-text">
|
<p className="supporting-text">
|
||||||
This room qualifies as the final cleared chamber. Completing the level will reveal
|
This room qualifies as the final cleared chamber. Completing the level will reveal
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ describe("level 1 content helpers", () => {
|
|||||||
expect(table.entries).toHaveLength(6);
|
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", () => {
|
it("resolves a small room template from a table lookup", () => {
|
||||||
const lookup = lookupTable(findTableByCode(sampleContentPack, "L1SR"), {
|
const lookup = lookupTable(findTableByCode(sampleContentPack, "L1SR"), {
|
||||||
roller: createSequenceRoller([3, 4]),
|
roller: createSequenceRoller([3, 4]),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,10 @@ import type { ContentPack } from "@/types/content";
|
|||||||
|
|
||||||
import { contentPackSchema } from "@/schemas/content";
|
import { contentPackSchema } from "@/schemas/content";
|
||||||
import { level1RoomTemplates } from "./level1Rooms";
|
import { level1RoomTemplates } from "./level1Rooms";
|
||||||
|
import {
|
||||||
|
getLevel1RoomObjects,
|
||||||
|
level1RoomInteractionTables,
|
||||||
|
} from "./level1RoomObjects";
|
||||||
import { level1EncounterTables } from "./level1Tables";
|
import { level1EncounterTables } from "./level1Tables";
|
||||||
|
|
||||||
const samplePack = {
|
const samplePack = {
|
||||||
@@ -13,6 +17,7 @@ const samplePack = {
|
|||||||
],
|
],
|
||||||
tables: [
|
tables: [
|
||||||
...level1EncounterTables,
|
...level1EncounterTables,
|
||||||
|
...level1RoomInteractionTables,
|
||||||
{
|
{
|
||||||
id: "table.level1.humanoid-loot",
|
id: "table.level1.humanoid-loot",
|
||||||
code: "L1HL",
|
code: "L1HL",
|
||||||
@@ -264,6 +269,492 @@ const samplePack = {
|
|||||||
valueGp: 12,
|
valueGp: 12,
|
||||||
mvp: true,
|
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: [
|
potions: [
|
||||||
{
|
{
|
||||||
@@ -274,6 +765,27 @@ const samplePack = {
|
|||||||
effects: [{ type: "heal", amount: 3, target: "self" }],
|
effects: [{ type: "heal", amount: 3, target: "self" }],
|
||||||
mvp: true,
|
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: [
|
scrolls: [
|
||||||
{
|
{
|
||||||
@@ -289,6 +801,12 @@ const samplePack = {
|
|||||||
startingOption: true,
|
startingOption: true,
|
||||||
mvp: 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: [
|
creatures: [
|
||||||
{
|
{
|
||||||
@@ -419,7 +937,10 @@ const samplePack = {
|
|||||||
tags: ["starter", "entry"],
|
tags: ["starter", "entry"],
|
||||||
mvp: true,
|
mvp: true,
|
||||||
},
|
},
|
||||||
...level1RoomTemplates,
|
...level1RoomTemplates.map((template) => ({
|
||||||
|
...template,
|
||||||
|
objects: getLevel1RoomObjects(template.id),
|
||||||
|
})),
|
||||||
],
|
],
|
||||||
townServices: [
|
townServices: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export function createStartingAdventurer(
|
|||||||
stored: [],
|
stored: [],
|
||||||
currency: {
|
currency: {
|
||||||
gold: 0,
|
gold: 0,
|
||||||
|
silver: 0,
|
||||||
},
|
},
|
||||||
rationCount: 3,
|
rationCount: 3,
|
||||||
lightSources: [makeInventoryEntry("item.lantern")],
|
lightSources: [makeInventoryEntry("item.lantern")],
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import type {
|
|||||||
import type { AdventurerState, CombatState, CombatantState } from "@/types/state";
|
import type { AdventurerState, CombatState, CombatantState } from "@/types/state";
|
||||||
import type { LogEntry } from "@/types/rules";
|
import type { LogEntry } from "@/types/rules";
|
||||||
|
|
||||||
|
import {
|
||||||
|
INSIGHTFUL_COMBAT_STATUS_ID,
|
||||||
|
SLEEPING_STATUS_ID,
|
||||||
|
consumeWardReduction,
|
||||||
|
consumeStatusValue,
|
||||||
|
} from "./magicItems";
|
||||||
import { roll2D6, type DiceRoller } from "./dice";
|
import { roll2D6, type DiceRoller } from "./dice";
|
||||||
|
|
||||||
export type ResolvePlayerAttackOptions = {
|
export type ResolvePlayerAttackOptions = {
|
||||||
@@ -138,9 +144,11 @@ export function resolvePlayerAttack(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const roll = roll2D6(options.roller);
|
const roll = roll2D6(options.roller);
|
||||||
|
const insightfulBonus = consumeStatusValue(combat.player.statuses, INSIGHTFUL_COMBAT_STATUS_ID);
|
||||||
const accuracy =
|
const accuracy =
|
||||||
(roll.total ?? 0) +
|
(roll.total ?? 0) +
|
||||||
combat.player.precision +
|
combat.player.precision +
|
||||||
|
insightfulBonus +
|
||||||
(manoeuvre.precisionModifier ?? 0);
|
(manoeuvre.precisionModifier ?? 0);
|
||||||
const targetNumber = BASE_TARGET_NUMBER + (target.armourValue ?? 0);
|
const targetNumber = BASE_TARGET_NUMBER + (target.armourValue ?? 0);
|
||||||
const hit = accuracy >= targetNumber;
|
const hit = accuracy >= targetNumber;
|
||||||
@@ -207,15 +215,42 @@ export function resolveEnemyTurn(
|
|||||||
throw new Error("No living enemies are available to act.");
|
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 roll = roll2D6(options.roller);
|
||||||
const armourValue = getPlayerArmourValue(options.content, options.adventurer);
|
const armourValue = getPlayerArmourValue(options.content, options.adventurer);
|
||||||
const accuracy = (roll.total ?? 0) + attacker.precision;
|
const accuracy = (roll.total ?? 0) + attacker.precision;
|
||||||
const targetNumber = BASE_TARGET_NUMBER + armourValue;
|
const targetNumber = BASE_TARGET_NUMBER + armourValue;
|
||||||
const hit = accuracy >= targetNumber;
|
const hit = accuracy >= targetNumber;
|
||||||
const rawDamage = hit ? Math.max(1, 1 + attacker.discipline) : 0;
|
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) {
|
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;
|
combat.lastRoll = roll;
|
||||||
@@ -227,7 +262,7 @@ export function resolveEnemyTurn(
|
|||||||
`${combat.id}.enemy.${combat.combatLog.length + 1}`,
|
`${combat.id}.enemy.${combat.combatLog.length + 1}`,
|
||||||
at,
|
at,
|
||||||
hit
|
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.name} attacks ${combat.player.name}, rolls ${roll.total}, and misses.`,
|
||||||
[attacker.id, combat.player.id],
|
[attacker.id, combat.player.id],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
import { findTableByCode } from "@/data/contentHelpers";
|
||||||
|
import type { ContentPack, RoomObjectTemplate, RoomTemplate } from "@/types/content";
|
||||||
|
import type { InventoryEntry, RoomObjectState, RoomState, RunState } from "@/types/state";
|
||||||
|
import type { ContentReference, LogEntry, RuleEffect } from "@/types/rules";
|
||||||
|
|
||||||
|
import type { DiceRoller } from "./dice";
|
||||||
|
import { rollDice } from "./dice";
|
||||||
|
import { consumeWardReduction } from "./magicItems";
|
||||||
|
import { lookupTable } from "./tables";
|
||||||
|
|
||||||
|
export type SearchRoomResult = {
|
||||||
|
run: RunState;
|
||||||
|
room: RoomState;
|
||||||
|
logEntries: LogEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolveRoomObjectOptions = {
|
||||||
|
content: ContentPack;
|
||||||
|
run: RunState;
|
||||||
|
room: RoomState;
|
||||||
|
objectId: string;
|
||||||
|
at?: string;
|
||||||
|
roller?: DiceRoller;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolveRoomObjectResult = {
|
||||||
|
run: RunState;
|
||||||
|
room: RoomState;
|
||||||
|
object: RoomObjectState;
|
||||||
|
logEntries: LogEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function createLogEntry(
|
||||||
|
id: string,
|
||||||
|
at: string,
|
||||||
|
type: LogEntry["type"],
|
||||||
|
text: string,
|
||||||
|
relatedIds?: string[],
|
||||||
|
): LogEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
at,
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
relatedIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemplateText(template: RoomTemplate) {
|
||||||
|
return `${template.title} ${template.text ?? ""} ${template.encounterText ?? ""}`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createObjectState(
|
||||||
|
templateId: string,
|
||||||
|
index: number,
|
||||||
|
object: RoomObjectTemplate,
|
||||||
|
): RoomObjectState {
|
||||||
|
return {
|
||||||
|
id: `${templateId}.object.${index + 1}`,
|
||||||
|
objectType: object.objectType,
|
||||||
|
title: object.title,
|
||||||
|
sourceTableCode: object.sourceTableCode,
|
||||||
|
interacted: false,
|
||||||
|
resolved: false,
|
||||||
|
hidden: object.hidden ?? false,
|
||||||
|
searchable:
|
||||||
|
object.searchable ?? (object.objectType === "container" || object.objectType === "corpse"),
|
||||||
|
notes: object.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHeuristicObjects(template: RoomTemplate): RoomObjectState[] {
|
||||||
|
const text = getTemplateText(template);
|
||||||
|
const objects: RoomObjectState[] = [];
|
||||||
|
|
||||||
|
const pushObject = (
|
||||||
|
objectType: RoomObjectState["objectType"],
|
||||||
|
title: string,
|
||||||
|
options?: Partial<RoomObjectState>,
|
||||||
|
) => {
|
||||||
|
objects.push({
|
||||||
|
id: `${template.id}.object.${objects.length + 1}`,
|
||||||
|
objectType,
|
||||||
|
title,
|
||||||
|
interacted: false,
|
||||||
|
searchable: objectType === "container" || objectType === "corpse",
|
||||||
|
hidden: objectType === "container" && template.tags.includes("search"),
|
||||||
|
resolved: false,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (text.includes("chest") || text.includes("crate") || text.includes("search")) {
|
||||||
|
pushObject("container", "Searchable Cache", {
|
||||||
|
sourceTableCode: "CT1",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes("corpse") || text.includes("body")) {
|
||||||
|
pushObject("corpse", "Fallen Body", {
|
||||||
|
sourceTableCode: "BST1",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.tags.includes("hazard")) {
|
||||||
|
pushObject("hazard", "Room Hazard", {
|
||||||
|
hidden: false,
|
||||||
|
searchable: false,
|
||||||
|
sourceTableCode: "L1TR",
|
||||||
|
notes: "This danger triggers when you meddle with the room.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes("altar")) {
|
||||||
|
pushObject("altar", "Strange Altar", {
|
||||||
|
hidden: false,
|
||||||
|
searchable: false,
|
||||||
|
sourceTableCode: "URL1",
|
||||||
|
notes: "The altar seems important and can be inspected.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes("prisoner")) {
|
||||||
|
pushObject("quest", "Possible Prisoner", {
|
||||||
|
hidden: true,
|
||||||
|
searchable: true,
|
||||||
|
sourceTableCode: "ENP1",
|
||||||
|
notes: "Searching may uncover a captive or hidden stash.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRoomObjectsFromTemplate(template: RoomTemplate): RoomObjectState[] {
|
||||||
|
if (template.objects?.length) {
|
||||||
|
return template.objects.map((object, index) => createObjectState(template.id, index, object));
|
||||||
|
}
|
||||||
|
|
||||||
|
return createHeuristicObjects(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
function awardEntry(run: RunState, definitionId: string, quantity = 1) {
|
||||||
|
const existing = run.adventurerSnapshot.inventory.carried.find(
|
||||||
|
(entry) => entry.definitionId === definitionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += quantity;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
run.adventurerSnapshot.inventory.carried.push({
|
||||||
|
definitionId,
|
||||||
|
quantity,
|
||||||
|
} satisfies InventoryEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAwardableReferenceType(referenceType: ContentReference["type"]) {
|
||||||
|
return ["item", "potion", "scroll", "armour", "weapon"].includes(referenceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRuleEffect(
|
||||||
|
run: RunState,
|
||||||
|
effect: RuleEffect,
|
||||||
|
roller?: DiceRoller,
|
||||||
|
): { gold: number; silver: number; items: number; damage: number; healing: number } {
|
||||||
|
const rolledAmount =
|
||||||
|
effect.diceKind
|
||||||
|
? Array.from({ length: effect.rollCount ?? 1 }, () => {
|
||||||
|
const roll = rollDice(effect.diceKind!, roller);
|
||||||
|
return roll.modifiedTotal ?? roll.total ?? 0;
|
||||||
|
}).reduce((total, value) => total + value, 0)
|
||||||
|
: 0;
|
||||||
|
const amount = (effect.amount ?? 0) + rolledAmount;
|
||||||
|
|
||||||
|
switch (effect.type) {
|
||||||
|
case "gain-gold": {
|
||||||
|
run.adventurerSnapshot.inventory.currency.gold += amount;
|
||||||
|
run.goldGained += amount;
|
||||||
|
return { gold: amount, silver: 0, items: 0, damage: 0, healing: 0 };
|
||||||
|
}
|
||||||
|
case "gain-silver": {
|
||||||
|
run.adventurerSnapshot.inventory.currency.silver += amount;
|
||||||
|
run.silverGained += amount;
|
||||||
|
return { gold: 0, silver: amount, items: 0, damage: 0, healing: 0 };
|
||||||
|
}
|
||||||
|
case "take-damage": {
|
||||||
|
const prevented = consumeWardReduction(run.adventurerSnapshot.statuses);
|
||||||
|
run.adventurerSnapshot.hp.current = Math.max(0, run.adventurerSnapshot.hp.current - amount);
|
||||||
|
if (prevented > 0) {
|
||||||
|
run.adventurerSnapshot.hp.current = Math.min(
|
||||||
|
run.adventurerSnapshot.hp.max,
|
||||||
|
run.adventurerSnapshot.hp.current + Math.min(prevented, amount),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (run.activeCombat) {
|
||||||
|
run.activeCombat.player.hpCurrent = run.adventurerSnapshot.hp.current;
|
||||||
|
consumeWardReduction(run.activeCombat.player.statuses);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
gold: 0,
|
||||||
|
silver: 0,
|
||||||
|
items: 0,
|
||||||
|
damage: Math.max(0, amount - prevented),
|
||||||
|
healing: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "heal": {
|
||||||
|
const current = run.adventurerSnapshot.hp.current;
|
||||||
|
const max = run.adventurerSnapshot.hp.max;
|
||||||
|
const healed = Math.max(0, Math.min(amount, max - current));
|
||||||
|
run.adventurerSnapshot.hp.current += healed;
|
||||||
|
if (run.activeCombat) {
|
||||||
|
run.activeCombat.player.hpCurrent = run.adventurerSnapshot.hp.current;
|
||||||
|
}
|
||||||
|
return { gold: 0, silver: 0, items: 0, damage: 0, healing: healed };
|
||||||
|
}
|
||||||
|
case "add-item": {
|
||||||
|
if (!effect.referenceId) {
|
||||||
|
return { gold: 0, silver: 0, items: 0, damage: 0, healing: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = effect.amount ?? 1;
|
||||||
|
awardEntry(run, effect.referenceId, quantity);
|
||||||
|
run.lootedItems.push({
|
||||||
|
definitionId: effect.referenceId,
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
|
return { gold: 0, silver: 0, items: quantity, damage: 0, healing: 0 };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { gold: 0, silver: 0, items: 0, damage: 0, healing: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeOutcome(summary: {
|
||||||
|
gold: number;
|
||||||
|
silver: number;
|
||||||
|
items: number;
|
||||||
|
damage: number;
|
||||||
|
healing: number;
|
||||||
|
}) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (summary.gold > 0) {
|
||||||
|
parts.push(`${summary.gold} gold`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.silver > 0) {
|
||||||
|
parts.push(`${summary.silver} silver`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.items > 0) {
|
||||||
|
parts.push(summary.items === 1 ? "1 item" : `${summary.items} items`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.damage > 0) {
|
||||||
|
parts.push(`${summary.damage} damage`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.healing > 0) {
|
||||||
|
parts.push(`${summary.healing} HP`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTableByReference(content: ContentPack, referenceId: string) {
|
||||||
|
const directMatch = content.tables.find(
|
||||||
|
(table) => table.id === referenceId || table.code === referenceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return directMatch ?? findTableByCode(content, referenceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReferences(options: {
|
||||||
|
content: ContentPack;
|
||||||
|
run: RunState;
|
||||||
|
room: RoomState;
|
||||||
|
object: RoomObjectState;
|
||||||
|
references: ContentReference[];
|
||||||
|
at: string;
|
||||||
|
roller?: DiceRoller;
|
||||||
|
depth: number;
|
||||||
|
}) {
|
||||||
|
const logEntries: LogEntry[] = [];
|
||||||
|
const summary = { gold: 0, silver: 0, items: 0, damage: 0, healing: 0 };
|
||||||
|
|
||||||
|
if (options.depth > 1) {
|
||||||
|
return { logEntries, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const reference of options.references) {
|
||||||
|
const quantity = reference.quantity ?? 1;
|
||||||
|
|
||||||
|
if (isAwardableReferenceType(reference.type)) {
|
||||||
|
awardEntry(options.run, reference.id, quantity);
|
||||||
|
options.run.lootedItems.push({
|
||||||
|
definitionId: reference.id,
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
|
summary.items += quantity;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reference.type !== "table") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = findTableByReference(options.content, reference.id);
|
||||||
|
|
||||||
|
for (let iteration = 0; iteration < quantity; iteration += 1) {
|
||||||
|
const lookup = lookupTable(table, { roller: options.roller });
|
||||||
|
const total = lookup.roll.modifiedTotal ?? lookup.roll.total;
|
||||||
|
const suffix = quantity > 1 ? ` (${iteration + 1}/${quantity})` : "";
|
||||||
|
|
||||||
|
logEntries.push(
|
||||||
|
createLogEntry(
|
||||||
|
`${options.room.id}.object.${options.object.id}.subroll.${table.code}.${options.depth}.${iteration + 1}`,
|
||||||
|
options.at,
|
||||||
|
"roll",
|
||||||
|
`Follow-up roll${suffix} ${lookup.roll.diceKind} [${lookup.roll.rolls.join(", ")}] on ${table.code} for ${total}: ${lookup.entry.label}.`,
|
||||||
|
[options.room.id, options.object.id, table.code],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const effect of lookup.entry.effects ?? []) {
|
||||||
|
const applied = applyRuleEffect(options.run, effect, options.roller);
|
||||||
|
summary.gold += applied.gold;
|
||||||
|
summary.silver += applied.silver;
|
||||||
|
summary.items += applied.items;
|
||||||
|
summary.damage += applied.damage;
|
||||||
|
summary.healing += applied.healing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = resolveReferences({
|
||||||
|
...options,
|
||||||
|
references: lookup.entry.references ?? [],
|
||||||
|
depth: options.depth + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
summary.gold += nested.summary.gold;
|
||||||
|
summary.silver += nested.summary.silver;
|
||||||
|
summary.items += nested.summary.items;
|
||||||
|
summary.damage += nested.summary.damage;
|
||||||
|
summary.healing += nested.summary.healing;
|
||||||
|
logEntries.push(...nested.logEntries);
|
||||||
|
|
||||||
|
logEntries.push(
|
||||||
|
createLogEntry(
|
||||||
|
`${options.room.id}.object.${options.object.id}.subresult.${table.code}.${options.depth}.${iteration + 1}`,
|
||||||
|
options.at,
|
||||||
|
"room",
|
||||||
|
`Follow-up result${suffix}: ${lookup.entry.text ?? lookup.entry.label}.`,
|
||||||
|
[options.room.id, options.object.id, table.code],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { logEntries, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchRoom(
|
||||||
|
run: RunState,
|
||||||
|
room: RoomState,
|
||||||
|
at = new Date().toISOString(),
|
||||||
|
): SearchRoomResult {
|
||||||
|
room.discovery.searched = true;
|
||||||
|
|
||||||
|
const hiddenObjects = room.objects.filter((object) => object.hidden);
|
||||||
|
|
||||||
|
hiddenObjects.forEach((object) => {
|
||||||
|
object.hidden = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const logEntries =
|
||||||
|
hiddenObjects.length > 0
|
||||||
|
? hiddenObjects.map((object, index) =>
|
||||||
|
createLogEntry(
|
||||||
|
`${room.id}.search.${index + 1}`,
|
||||||
|
at,
|
||||||
|
"room",
|
||||||
|
`Searching ${room.id} reveals ${object.title}.`,
|
||||||
|
[room.id, object.id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
createLogEntry(
|
||||||
|
`${room.id}.search.empty`,
|
||||||
|
at,
|
||||||
|
"room",
|
||||||
|
`Searched ${room.id} but found nothing new.`,
|
||||||
|
[room.id],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
room,
|
||||||
|
logEntries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRoomObject(
|
||||||
|
options: ResolveRoomObjectOptions,
|
||||||
|
): ResolveRoomObjectResult {
|
||||||
|
const room = options.room;
|
||||||
|
const object = room.objects.find((entry) => entry.id === options.objectId);
|
||||||
|
|
||||||
|
if (!object) {
|
||||||
|
throw new Error(`Unknown room object id: ${options.objectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (object.hidden) {
|
||||||
|
throw new Error(`Room object ${options.objectId} is still hidden.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (object.interacted) {
|
||||||
|
throw new Error(`Room object ${options.objectId} has already been resolved.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = options.at ?? new Date().toISOString();
|
||||||
|
const logEntries: LogEntry[] = [];
|
||||||
|
|
||||||
|
object.interacted = true;
|
||||||
|
object.resolved = true;
|
||||||
|
|
||||||
|
if (object.sourceTableCode) {
|
||||||
|
const table = findTableByCode(options.content, object.sourceTableCode);
|
||||||
|
const lookup = lookupTable(table, { roller: options.roller });
|
||||||
|
const total = lookup.roll.modifiedTotal ?? lookup.roll.total;
|
||||||
|
const summary = { gold: 0, silver: 0, items: 0, damage: 0, healing: 0 };
|
||||||
|
|
||||||
|
object.resolutionLabel = lookup.entry.label;
|
||||||
|
object.resolutionEntryKey = lookup.entry.key;
|
||||||
|
|
||||||
|
logEntries.push(
|
||||||
|
createLogEntry(
|
||||||
|
`${room.id}.object.${object.id}.roll`,
|
||||||
|
at,
|
||||||
|
"roll",
|
||||||
|
`Rolled ${lookup.roll.diceKind} [${lookup.roll.rolls.join(", ")}] on ${object.sourceTableCode} for ${total}: ${lookup.entry.label}.`,
|
||||||
|
[room.id, object.id, object.sourceTableCode],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const effect of lookup.entry.effects ?? []) {
|
||||||
|
const applied = applyRuleEffect(options.run, effect, options.roller);
|
||||||
|
summary.gold += applied.gold;
|
||||||
|
summary.silver += applied.silver;
|
||||||
|
summary.items += applied.items;
|
||||||
|
summary.damage += applied.damage;
|
||||||
|
summary.healing += applied.healing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceResolution = resolveReferences({
|
||||||
|
content: options.content,
|
||||||
|
run: options.run,
|
||||||
|
room,
|
||||||
|
object,
|
||||||
|
references: lookup.entry.references ?? [],
|
||||||
|
at,
|
||||||
|
roller: options.roller,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
summary.gold += referenceResolution.summary.gold;
|
||||||
|
summary.items += referenceResolution.summary.items;
|
||||||
|
summary.damage += referenceResolution.summary.damage;
|
||||||
|
summary.healing += referenceResolution.summary.healing;
|
||||||
|
logEntries.push(...referenceResolution.logEntries);
|
||||||
|
|
||||||
|
logEntries.push(
|
||||||
|
createLogEntry(
|
||||||
|
`${room.id}.object.${object.id}.result`,
|
||||||
|
at,
|
||||||
|
"room",
|
||||||
|
`${object.title}: ${lookup.entry.text ?? lookup.entry.label}${summarizeOutcome(summary) ? ` (${summarizeOutcome(summary)})` : ""}.`,
|
||||||
|
[room.id, object.id, object.sourceTableCode],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
run: options.run,
|
||||||
|
room,
|
||||||
|
object,
|
||||||
|
logEntries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackSummary = [
|
||||||
|
object.rewardGold ? `${object.rewardGold} gold` : undefined,
|
||||||
|
object.rewardItemId ? "1 item" : undefined,
|
||||||
|
object.damage ? `${object.damage} damage` : undefined,
|
||||||
|
].filter((entry): entry is string => Boolean(entry));
|
||||||
|
|
||||||
|
if (object.rewardGold) {
|
||||||
|
options.run.adventurerSnapshot.inventory.currency.gold += object.rewardGold;
|
||||||
|
options.run.goldGained += object.rewardGold;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (object.rewardItemId) {
|
||||||
|
awardEntry(options.run, object.rewardItemId);
|
||||||
|
options.run.lootedItems.push({
|
||||||
|
definitionId: object.rewardItemId,
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (object.damage) {
|
||||||
|
options.run.adventurerSnapshot.hp.current = Math.max(
|
||||||
|
0,
|
||||||
|
options.run.adventurerSnapshot.hp.current - object.damage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logEntries.push(
|
||||||
|
createLogEntry(
|
||||||
|
`${room.id}.object.${object.id}.result`,
|
||||||
|
at,
|
||||||
|
"room",
|
||||||
|
`${object.title} resolved${fallbackSummary.length > 0 ? `: ${fallbackSummary.join(", ")}.` : "."}`,
|
||||||
|
[room.id, object.id],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
run: options.run,
|
||||||
|
room,
|
||||||
|
object,
|
||||||
|
logEntries,
|
||||||
|
};
|
||||||
|
}
|
||||||
+2
-1
@@ -8,6 +8,7 @@ import type { DungeonLevelState, RoomExitState, RoomState } from "@/types/state"
|
|||||||
|
|
||||||
import { lookupTable, type TableLookupResult } from "./tables";
|
import { lookupTable, type TableLookupResult } from "./tables";
|
||||||
import type { DiceRoller } from "./dice";
|
import type { DiceRoller } from "./dice";
|
||||||
|
import { createRoomObjectsFromTemplate } from "./roomObjects";
|
||||||
|
|
||||||
export type RoomGenerationOptions = {
|
export type RoomGenerationOptions = {
|
||||||
content: ContentPack;
|
content: ContentPack;
|
||||||
@@ -148,7 +149,7 @@ export function createRoomStateFromTemplate(
|
|||||||
searched: false,
|
searched: false,
|
||||||
},
|
},
|
||||||
encounter: undefined,
|
encounter: undefined,
|
||||||
objects: [],
|
objects: createRoomObjectsFromTemplate(template),
|
||||||
notes: [template.text ?? template.title, template.encounterText].filter(
|
notes: [template.text ?? template.title, template.encounterText].filter(
|
||||||
(note): note is string => Boolean(note),
|
(note): note is string => Boolean(note),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { sampleContentPack } from "@/data/sampleContentPack";
|
import { sampleContentPack } from "@/data/sampleContentPack";
|
||||||
|
|
||||||
import { createStartingAdventurer } from "./character";
|
import { createStartingAdventurer } from "./character";
|
||||||
|
import { createRoomStateFromTemplate } from "./rooms";
|
||||||
import {
|
import {
|
||||||
canCompleteCurrentLevel,
|
canCompleteCurrentLevel,
|
||||||
completeCurrentLevel,
|
completeCurrentLevel,
|
||||||
@@ -10,12 +11,15 @@ import {
|
|||||||
enterCurrentRoom,
|
enterCurrentRoom,
|
||||||
getAvailableMoves,
|
getAvailableMoves,
|
||||||
isCurrentRoomCombatReady,
|
isCurrentRoomCombatReady,
|
||||||
|
resolveCurrentRoomObject,
|
||||||
resolveRunEnemyTurn,
|
resolveRunEnemyTurn,
|
||||||
resolveRunPlayerTurn,
|
resolveRunPlayerTurn,
|
||||||
resumeDungeon,
|
resumeDungeon,
|
||||||
returnToTown,
|
returnToTown,
|
||||||
|
searchCurrentRoom,
|
||||||
startCombatInCurrentRoom,
|
startCombatInCurrentRoom,
|
||||||
travelCurrentExit,
|
travelCurrentExit,
|
||||||
|
useRunMagicItem,
|
||||||
} from "./runState";
|
} from "./runState";
|
||||||
|
|
||||||
function createSequenceRoller(values: number[]) {
|
function createSequenceRoller(values: number[]) {
|
||||||
@@ -372,6 +376,362 @@ describe("run state flow", () => {
|
|||||||
expect(isCurrentRoomCombatReady(run)).toBe(true);
|
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", () => {
|
it("returns to town and later resumes the dungeon", () => {
|
||||||
const run = createRunState({
|
const run = createRunState({
|
||||||
content: sampleContentPack,
|
content: sampleContentPack,
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ import { startCombatFromRoom } from "./combat";
|
|||||||
import { createInitialTownState } from "./townServices";
|
import { createInitialTownState } from "./townServices";
|
||||||
import { resolveCombatLoot } from "./loot";
|
import { resolveCombatLoot } from "./loot";
|
||||||
import { applyLevelProgression } from "./progression";
|
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 {
|
import {
|
||||||
resolveEnemyTurn,
|
resolveEnemyTurn,
|
||||||
resolvePlayerAttack,
|
resolvePlayerAttack,
|
||||||
@@ -28,6 +39,7 @@ import {
|
|||||||
} from "./dungeon";
|
} from "./dungeon";
|
||||||
import type { DiceRoller } from "./dice";
|
import type { DiceRoller } from "./dice";
|
||||||
import { enterRoom } from "./roomEntry";
|
import { enterRoom } from "./roomEntry";
|
||||||
|
import { resolveRoomObject, searchRoom } from "./roomObjects";
|
||||||
|
|
||||||
export type CreateRunOptions = {
|
export type CreateRunOptions = {
|
||||||
content: ContentPack;
|
content: ContentPack;
|
||||||
@@ -88,6 +100,22 @@ export type RunTransitionResult = {
|
|||||||
logEntries: LogEntry[];
|
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) {
|
function appendDungeonFlag(run: RunState, flag: string) {
|
||||||
if (!run.dungeon.globalFlags.includes(flag)) {
|
if (!run.dungeon.globalFlags.includes(flag)) {
|
||||||
run.dungeon.globalFlags.push(flag);
|
run.dungeon.globalFlags.push(flag);
|
||||||
@@ -448,6 +476,7 @@ export function createRunState(options: CreateRunOptions): RunState {
|
|||||||
defeatedCreatureIds: [],
|
defeatedCreatureIds: [],
|
||||||
xpGained: 0,
|
xpGained: 0,
|
||||||
goldGained: 0,
|
goldGained: 0,
|
||||||
|
silverGained: 0,
|
||||||
lootedItems: [],
|
lootedItems: [],
|
||||||
log: [],
|
log: [],
|
||||||
pendingEffects: [],
|
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(
|
export function travelCurrentExit(
|
||||||
options: TravelCurrentExitOptions,
|
options: TravelCurrentExitOptions,
|
||||||
): RunTransitionResult {
|
): RunTransitionResult {
|
||||||
@@ -879,3 +964,282 @@ export function resolveRunEnemyTurn(
|
|||||||
logEntries: result.logEntries,
|
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}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -157,6 +157,15 @@ export const exitTemplateSchema = z.object({
|
|||||||
destinationLevel: z.number().int().optional(),
|
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({
|
export const roomTemplateSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
level: z.number().int().positive(),
|
level: z.number().int().positive(),
|
||||||
@@ -176,6 +185,7 @@ export const roomTemplateSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
exits: z.array(exitTemplateSchema).optional(),
|
exits: z.array(exitTemplateSchema).optional(),
|
||||||
|
objects: z.array(roomObjectTemplateSchema).optional(),
|
||||||
encounterRefs: z.array(contentReferenceSchema).optional(),
|
encounterRefs: z.array(contentReferenceSchema).optional(),
|
||||||
objectRefs: z.array(contentReferenceSchema).optional(),
|
objectRefs: z.array(contentReferenceSchema).optional(),
|
||||||
tags: z.array(z.string()),
|
tags: z.array(z.string()),
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ export const contentReferenceTypeSchema = z.enum([
|
|||||||
export const contentReferenceSchema = z.object({
|
export const contentReferenceSchema = z.object({
|
||||||
type: contentReferenceTypeSchema,
|
type: contentReferenceTypeSchema,
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
|
quantity: z.number().int().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ruleEffectSchema = z.object({
|
export const ruleEffectSchema = z.object({
|
||||||
type: z.enum([
|
type: z.enum([
|
||||||
"gain-xp",
|
"gain-xp",
|
||||||
"gain-gold",
|
"gain-gold",
|
||||||
|
"gain-silver",
|
||||||
"heal",
|
"heal",
|
||||||
"take-damage",
|
"take-damage",
|
||||||
"modify-shift",
|
"modify-shift",
|
||||||
@@ -38,6 +40,8 @@ export const ruleEffectSchema = z.object({
|
|||||||
"log-only",
|
"log-only",
|
||||||
]),
|
]),
|
||||||
amount: z.number().optional(),
|
amount: z.number().optional(),
|
||||||
|
diceKind: diceKindSchema.optional(),
|
||||||
|
rollCount: z.number().int().positive().optional(),
|
||||||
statusId: z.string().optional(),
|
statusId: z.string().optional(),
|
||||||
target: z.enum(["self", "enemy", "room", "campaign"]).optional(),
|
target: z.enum(["self", "enemy", "room", "campaign"]).optional(),
|
||||||
referenceId: z.string().optional(),
|
referenceId: z.string().optional(),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const inventoryStateSchema = z.object({
|
|||||||
stored: z.array(inventoryEntrySchema),
|
stored: z.array(inventoryEntrySchema),
|
||||||
currency: z.object({
|
currency: z.object({
|
||||||
gold: z.number().int().nonnegative(),
|
gold: z.number().int().nonnegative(),
|
||||||
|
silver: z.number().int().nonnegative(),
|
||||||
}),
|
}),
|
||||||
rationCount: z.number().int().nonnegative(),
|
rationCount: z.number().int().nonnegative(),
|
||||||
lightSources: z.array(inventoryEntrySchema),
|
lightSources: z.array(inventoryEntrySchema),
|
||||||
@@ -120,9 +121,17 @@ export const encounterStateSchema = z.object({
|
|||||||
export const roomObjectStateSchema = z.object({
|
export const roomObjectStateSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
objectType: z.enum(["container", "altar", "corpse", "hazard", "feature", "quest"]),
|
objectType: z.enum(["container", "altar", "corpse", "hazard", "feature", "quest"]),
|
||||||
|
title: z.string().min(1),
|
||||||
sourceTableCode: z.string().optional(),
|
sourceTableCode: z.string().optional(),
|
||||||
interacted: z.boolean(),
|
interacted: z.boolean(),
|
||||||
|
resolved: z.boolean().optional(),
|
||||||
hidden: 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(),
|
effects: z.array(ruleEffectSchema).optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -239,6 +248,7 @@ export const runStateSchema = z.object({
|
|||||||
defeatedCreatureIds: z.array(z.string()),
|
defeatedCreatureIds: z.array(z.string()),
|
||||||
xpGained: z.number().int().nonnegative(),
|
xpGained: z.number().int().nonnegative(),
|
||||||
goldGained: z.number().int().nonnegative(),
|
goldGained: z.number().int().nonnegative(),
|
||||||
|
silverGained: z.number().int().nonnegative(),
|
||||||
lootedItems: z.array(inventoryEntrySchema),
|
lootedItems: z.array(inventoryEntrySchema),
|
||||||
log: z.array(logEntrySchema),
|
log: z.array(logEntrySchema),
|
||||||
pendingEffects: z.array(ruleEffectSchema),
|
pendingEffects: z.array(ruleEffectSchema),
|
||||||
|
|||||||
@@ -155,12 +155,29 @@ export type CreatureDefinition = {
|
|||||||
|
|
||||||
export type ExitType = "open" | "door" | "locked" | "secret" | "shaft" | "stairs";
|
export type ExitType = "open" | "door" | "locked" | "secret" | "shaft" | "stairs";
|
||||||
|
|
||||||
|
export type RoomObjectType =
|
||||||
|
| "container"
|
||||||
|
| "altar"
|
||||||
|
| "corpse"
|
||||||
|
| "hazard"
|
||||||
|
| "feature"
|
||||||
|
| "quest";
|
||||||
|
|
||||||
export type ExitTemplate = {
|
export type ExitTemplate = {
|
||||||
direction?: "north" | "east" | "south" | "west";
|
direction?: "north" | "east" | "south" | "west";
|
||||||
exitType: ExitType;
|
exitType: ExitType;
|
||||||
destinationLevel?: number;
|
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 RoomClass = "normal" | "small" | "large" | "special" | "start" | "stairs";
|
||||||
|
|
||||||
export type RoomTemplate = {
|
export type RoomTemplate = {
|
||||||
@@ -180,6 +197,7 @@ export type RoomTemplate = {
|
|||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
exits?: ExitTemplate[];
|
exits?: ExitTemplate[];
|
||||||
|
objects?: RoomObjectTemplate[];
|
||||||
encounterRefs?: ContentReference[];
|
encounterRefs?: ContentReference[];
|
||||||
objectRefs?: ContentReference[];
|
objectRefs?: ContentReference[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ export type ContentReferenceType =
|
|||||||
export type ContentReference = {
|
export type ContentReference = {
|
||||||
type: ContentReferenceType;
|
type: ContentReferenceType;
|
||||||
id: string;
|
id: string;
|
||||||
|
quantity?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RuleEffectType =
|
export type RuleEffectType =
|
||||||
| "gain-xp"
|
| "gain-xp"
|
||||||
| "gain-gold"
|
| "gain-gold"
|
||||||
|
| "gain-silver"
|
||||||
| "heal"
|
| "heal"
|
||||||
| "take-damage"
|
| "take-damage"
|
||||||
| "modify-shift"
|
| "modify-shift"
|
||||||
@@ -39,6 +41,8 @@ export type RuleEffectTarget = "self" | "enemy" | "room" | "campaign";
|
|||||||
export type RuleEffect = {
|
export type RuleEffect = {
|
||||||
type: RuleEffectType;
|
type: RuleEffectType;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
|
diceKind?: DiceKind;
|
||||||
|
rollCount?: number;
|
||||||
statusId?: string;
|
statusId?: string;
|
||||||
target?: RuleEffectTarget;
|
target?: RuleEffectTarget;
|
||||||
referenceId?: string;
|
referenceId?: string;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type InventoryState = {
|
|||||||
stored: InventoryEntry[];
|
stored: InventoryEntry[];
|
||||||
currency: {
|
currency: {
|
||||||
gold: number;
|
gold: number;
|
||||||
|
silver: number;
|
||||||
};
|
};
|
||||||
rationCount: number;
|
rationCount: number;
|
||||||
lightSources: InventoryEntry[];
|
lightSources: InventoryEntry[];
|
||||||
@@ -121,9 +122,17 @@ export type EncounterState = {
|
|||||||
export type RoomObjectState = {
|
export type RoomObjectState = {
|
||||||
id: string;
|
id: string;
|
||||||
objectType: "container" | "altar" | "corpse" | "hazard" | "feature" | "quest";
|
objectType: "container" | "altar" | "corpse" | "hazard" | "feature" | "quest";
|
||||||
|
title: string;
|
||||||
sourceTableCode?: string;
|
sourceTableCode?: string;
|
||||||
interacted: boolean;
|
interacted: boolean;
|
||||||
|
resolved?: boolean;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
searchable?: boolean;
|
||||||
|
rewardItemId?: string;
|
||||||
|
rewardGold?: number;
|
||||||
|
damage?: number;
|
||||||
|
resolutionLabel?: string;
|
||||||
|
resolutionEntryKey?: string;
|
||||||
effects?: RuleEffect[];
|
effects?: RuleEffect[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
};
|
};
|
||||||
@@ -240,6 +249,7 @@ export type RunState = {
|
|||||||
defeatedCreatureIds: string[];
|
defeatedCreatureIds: string[];
|
||||||
xpGained: number;
|
xpGained: number;
|
||||||
goldGained: number;
|
goldGained: number;
|
||||||
|
silverGained: number;
|
||||||
lootedItems: InventoryEntry[];
|
lootedItems: InventoryEntry[];
|
||||||
log: LogEntry[];
|
log: LogEntry[];
|
||||||
pendingEffects: RuleEffect[];
|
pendingEffects: RuleEffect[];
|
||||||
|
|||||||
Reference in New Issue
Block a user