Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0182e9eb79 | |||
| 9f494461de | |||
| 37e2b27870 | |||
| bcd720cae8 | |||
| c616ead519 | |||
| 8600f611a6 | |||
| a4d2890cd9 | |||
| d3183a92b6 | |||
| 1613acd39b | |||
| 2c58c3f1a5 | |||
| 967766956f | |||
| 9eef50e0c9 | |||
| cbb3efafd7 | |||
| 626d5ca05c | |||
| 68654a8cc0 | |||
| 40ce9644ab | |||
| 8597b4fded | |||
| 71bdc6d031 | |||
| 6c2257b032 | |||
| cf98636a52 | |||
| 473ea83cdf | |||
| 7fb3bd6cf5 | |||
| fb6cbfe9fb | |||
| 9c7acf6825 | |||
| 102cbfeaad | |||
| 377a533466 | |||
| d504008030 | |||
| 5debb5bd5e |
@@ -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/
|
||||||
|
|||||||
+1216
-50
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,10 @@ import { lookupTable } from "@/rules/tables";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
findCreatureByName,
|
findCreatureByName,
|
||||||
|
findItemById,
|
||||||
findRoomTemplateForLookup,
|
findRoomTemplateForLookup,
|
||||||
|
findPotionById,
|
||||||
|
findScrollById,
|
||||||
findTableByCode,
|
findTableByCode,
|
||||||
} from "./contentHelpers";
|
} from "./contentHelpers";
|
||||||
import { sampleContentPack } from "./sampleContentPack";
|
import { sampleContentPack } from "./sampleContentPack";
|
||||||
@@ -27,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]),
|
||||||
@@ -57,4 +74,23 @@ describe("level 1 content helpers", () => {
|
|||||||
expect(creature.id).toBe("creature.level1.guard");
|
expect(creature.id).toBe("creature.level1.guard");
|
||||||
expect(creature.hp).toBeGreaterThan(0);
|
expect(creature.hp).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("finds an item definition by id", () => {
|
||||||
|
const item = findItemById(sampleContentPack, "item.garnet-ring");
|
||||||
|
|
||||||
|
expect(item.itemType).toBe("treasure");
|
||||||
|
expect(item.valueGp).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds a loot item definition by id", () => {
|
||||||
|
const item = findItemById(sampleContentPack, "item.silver-clasp");
|
||||||
|
|
||||||
|
expect(item.name).toBe("Silver Clasp");
|
||||||
|
expect(item.itemType).toBe("treasure");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds potion and scroll definitions by id", () => {
|
||||||
|
expect(findPotionById(sampleContentPack, "potion.healing").name).toBe("Healing Potion");
|
||||||
|
expect(findScrollById(sampleContentPack, "scroll.lesser-heal").name).toBe("Lesser Heal");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
ContentPack,
|
ContentPack,
|
||||||
CreatureDefinition,
|
CreatureDefinition,
|
||||||
|
ItemDefinition,
|
||||||
|
PotionDefinition,
|
||||||
RoomTemplate,
|
RoomTemplate,
|
||||||
|
ScrollDefinition,
|
||||||
TableDefinition,
|
TableDefinition,
|
||||||
} from "@/types/content";
|
} from "@/types/content";
|
||||||
import type { TableLookupResult } from "@/rules/tables";
|
import type { TableLookupResult } from "@/rules/tables";
|
||||||
@@ -61,3 +64,46 @@ export function findCreatureByName(
|
|||||||
|
|
||||||
return creature;
|
return creature;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findCreatureById(
|
||||||
|
content: ContentPack,
|
||||||
|
creatureId: string,
|
||||||
|
): CreatureDefinition {
|
||||||
|
const creature = content.creatures.find((entry) => entry.id === creatureId);
|
||||||
|
|
||||||
|
if (!creature) {
|
||||||
|
throw new Error(`Unknown creature id: ${creatureId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return creature;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findItemById(content: ContentPack, itemId: string): ItemDefinition {
|
||||||
|
const item = content.items.find((entry) => entry.id === itemId);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new Error(`Unknown item id: ${itemId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPotionById(content: ContentPack, potionId: string): PotionDefinition {
|
||||||
|
const potion = content.potions.find((entry) => entry.id === potionId);
|
||||||
|
|
||||||
|
if (!potion) {
|
||||||
|
throw new Error(`Unknown potion id: ${potionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return potion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findScrollById(content: ContentPack, scrollId: string): ScrollDefinition {
|
||||||
|
const scroll = content.scrolls.find((entry) => entry.id === scrollId);
|
||||||
|
|
||||||
|
if (!scroll) {
|
||||||
|
throw new Error(`Unknown scroll id: ${scrollId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return scroll;
|
||||||
|
}
|
||||||
|
|||||||
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,80 @@ const samplePack = {
|
|||||||
],
|
],
|
||||||
tables: [
|
tables: [
|
||||||
...level1EncounterTables,
|
...level1EncounterTables,
|
||||||
|
...level1RoomInteractionTables,
|
||||||
|
{
|
||||||
|
id: "table.level1.humanoid-loot",
|
||||||
|
code: "L1HL",
|
||||||
|
name: "Level 1 Humanoid Loot",
|
||||||
|
category: "loot",
|
||||||
|
level: 1,
|
||||||
|
page: 122,
|
||||||
|
diceKind: "d6",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
key: "1-2",
|
||||||
|
min: 1,
|
||||||
|
max: 2,
|
||||||
|
label: "Scavenged coins",
|
||||||
|
effects: [{ type: "gain-gold", amount: 1, target: "self" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "3-4",
|
||||||
|
min: 3,
|
||||||
|
max: 4,
|
||||||
|
label: "Bone Charm",
|
||||||
|
references: [{ type: "item", id: "item.bone-charm" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "5",
|
||||||
|
exact: 5,
|
||||||
|
label: "Silver Clasp and coins",
|
||||||
|
references: [{ type: "item", id: "item.silver-clasp" }],
|
||||||
|
effects: [{ type: "gain-gold", amount: 2, target: "self" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "6",
|
||||||
|
exact: 6,
|
||||||
|
label: "Key Ring and coin purse",
|
||||||
|
references: [{ type: "item", id: "item.keeper-keyring" }],
|
||||||
|
effects: [{ type: "gain-gold", amount: 3, target: "self" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: ["Starter loot table for martial and humanoid encounters."],
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "table.level1.beast-loot",
|
||||||
|
code: "L1BL",
|
||||||
|
name: "Level 1 Beast Loot",
|
||||||
|
category: "loot",
|
||||||
|
level: 1,
|
||||||
|
page: 122,
|
||||||
|
diceKind: "d6",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
key: "1-4",
|
||||||
|
min: 1,
|
||||||
|
max: 4,
|
||||||
|
label: "Nothing useful",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "5",
|
||||||
|
exact: 5,
|
||||||
|
label: "Trophy Fang",
|
||||||
|
references: [{ type: "item", id: "item.trophy-fang" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "6",
|
||||||
|
exact: 6,
|
||||||
|
label: "Trophy Fang and stray coin",
|
||||||
|
references: [{ type: "item", id: "item.trophy-fang" }],
|
||||||
|
effects: [{ type: "gain-gold", amount: 1, target: "self" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: ["Starter loot table for basic animal encounters."],
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "table.weapon-manoeuvres-1",
|
id: "table.weapon-manoeuvres-1",
|
||||||
code: "WMT1",
|
code: "WMT1",
|
||||||
@@ -39,7 +117,11 @@ const samplePack = {
|
|||||||
category: "melee",
|
category: "melee",
|
||||||
handedness: "one-handed",
|
handedness: "one-handed",
|
||||||
baseDamage: 1,
|
baseDamage: 1,
|
||||||
allowedManoeuvreIds: ["manoeuvre.exact-strike", "manoeuvre.guard-break"],
|
allowedManoeuvreIds: [
|
||||||
|
"manoeuvre.exact-strike",
|
||||||
|
"manoeuvre.guard-break",
|
||||||
|
"manoeuvre.sweeping-cut",
|
||||||
|
],
|
||||||
tags: ["starter"],
|
tags: ["starter"],
|
||||||
startingOption: true,
|
startingOption: true,
|
||||||
},
|
},
|
||||||
@@ -63,6 +145,16 @@ const samplePack = {
|
|||||||
effectText: "Trades shift for a stronger hit.",
|
effectText: "Trades shift for a stronger hit.",
|
||||||
mvp: true,
|
mvp: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "manoeuvre.sweeping-cut",
|
||||||
|
name: "Sweeping Cut",
|
||||||
|
weaponCategories: ["melee"],
|
||||||
|
minimumLevel: 2,
|
||||||
|
shiftCost: 1,
|
||||||
|
damageModifier: 2,
|
||||||
|
effectText: "A heavier follow-through unlocked after the first level-up.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
armour: [
|
armour: [
|
||||||
{
|
{
|
||||||
@@ -123,6 +215,546 @@ const samplePack = {
|
|||||||
consumable: false,
|
consumable: false,
|
||||||
mvp: true,
|
mvp: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "item.bone-charm",
|
||||||
|
name: "Bone Charm",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 2,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.silver-clasp",
|
||||||
|
name: "Silver Clasp",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 4,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.keeper-keyring",
|
||||||
|
name: "Keeper Keyring",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 5,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.trophy-fang",
|
||||||
|
name: "Trophy Fang",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: true,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 1,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.silver-chalice",
|
||||||
|
name: "Silver Chalice",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 8,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.garnet-ring",
|
||||||
|
name: "Garnet Ring",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 12,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.malako-leaves",
|
||||||
|
name: "Malako Leaves",
|
||||||
|
itemType: "herb",
|
||||||
|
stackable: true,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 2,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.dankoma-stems",
|
||||||
|
name: "Dankoma Stems",
|
||||||
|
itemType: "herb",
|
||||||
|
stackable: true,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 2,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.redroot-spines",
|
||||||
|
name: "Redroot Spines",
|
||||||
|
itemType: "herb",
|
||||||
|
stackable: true,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 2,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.wolf-worm-eggs",
|
||||||
|
name: "Wolf Worm Eggs",
|
||||||
|
itemType: "herb",
|
||||||
|
stackable: true,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 2,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.scarlet-ore-leaves",
|
||||||
|
name: "Scarlet Ore Leaves",
|
||||||
|
itemType: "herb",
|
||||||
|
stackable: true,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 2,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.oretauts-leaves",
|
||||||
|
name: "Oretauts Leaves",
|
||||||
|
itemType: "herb",
|
||||||
|
stackable: true,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 2,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.pearl",
|
||||||
|
name: "Pearl",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: true,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 5,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.sapphire",
|
||||||
|
name: "Sapphire",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: true,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 10,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.garnet",
|
||||||
|
name: "Garnet",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: true,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 8,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.ruby",
|
||||||
|
name: "Ruby",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: true,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 12,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.emerald",
|
||||||
|
name: "Emerald",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: true,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 12,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.diamond",
|
||||||
|
name: "Diamond",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: true,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 20,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.half-copper-pendant",
|
||||||
|
name: "Half a Copper Pendant",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 5,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.half-gold-pendant",
|
||||||
|
name: "Half a Gold Pendant",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 5,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.half-gold-cross",
|
||||||
|
name: "Half a Gold Cross",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 20,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.half-silver-cross",
|
||||||
|
name: "Half a Silver Cross",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 3,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.half-gold-symbol",
|
||||||
|
name: "Half a Gold Symbol",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 15,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.half-gold-symbol-high",
|
||||||
|
name: "Half a Gold Symbol",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 40,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.god-ornate-goada",
|
||||||
|
name: "Goada the Helm",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 18,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.god-ornate-intuneric",
|
||||||
|
name: "Intuneric the Murk",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 18,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.god-ornate-murtayne",
|
||||||
|
name: "Murtayne the Pup",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 18,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.god-ornate-nevzator",
|
||||||
|
name: "Nevzator the Blind",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 18,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.god-ornate-radacina",
|
||||||
|
name: "Radacina the X",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 18,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.god-ornate-madi",
|
||||||
|
name: "Madi the Sphere",
|
||||||
|
itemType: "treasure",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 18,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.ring-encountered",
|
||||||
|
name: "Encountered Ring",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 20,
|
||||||
|
rulesText: "Magic ring from MR1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.ring-of-baseness",
|
||||||
|
name: "Ring of Baseness",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 20,
|
||||||
|
rulesText: "Magic ring from MR1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.ring-of-spells",
|
||||||
|
name: "Ring of Spells",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 25,
|
||||||
|
rulesText: "Magic ring from MR1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.ring-of-steadiness",
|
||||||
|
name: "Ring of Steadiness",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 25,
|
||||||
|
rulesText: "Magic ring from MR1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.ring-of-transformation",
|
||||||
|
name: "Ring of Transformation",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 30,
|
||||||
|
rulesText: "Magic ring from MR1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.ring-of-leaving",
|
||||||
|
name: "Ring of Leaving",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 30,
|
||||||
|
rulesText: "Magic ring from MR1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.amulet-of-resistance",
|
||||||
|
name: "Amulet of Resistance",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 30,
|
||||||
|
rulesText: "Magic amulet from MA1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.amulet-of-fire-resistance",
|
||||||
|
name: "Amulet of Fire Resistance",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 35,
|
||||||
|
rulesText: "Magic amulet from MA1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.amulet-of-ice-resistance",
|
||||||
|
name: "Amulet of Ice Resistance",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 35,
|
||||||
|
rulesText: "Magic amulet from MA1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.amulet-of-poison-resistance",
|
||||||
|
name: "Amulet of Poison Resistance",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 35,
|
||||||
|
rulesText: "Magic amulet from MA1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.wand-of-fireballs",
|
||||||
|
name: "Wand of Fireballs",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 50,
|
||||||
|
rulesText: "Magic wand from MW1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.wand-of-fire",
|
||||||
|
name: "Wand of Fire",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 40,
|
||||||
|
rulesText: "Magic wand from MW1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.wand-of-sunder",
|
||||||
|
name: "Wand of Sunder",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 45,
|
||||||
|
rulesText: "Magic wand from MW1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.wand-of-sleep",
|
||||||
|
name: "Wand of Sleep",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 40,
|
||||||
|
rulesText: "Magic wand from MW1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.wand-of-paralysis",
|
||||||
|
name: "Wand of Paralysis",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 45,
|
||||||
|
rulesText: "Magic wand from MW1; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.potion-of-swamp-lung",
|
||||||
|
name: "Potion of Swamp Lung",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: true,
|
||||||
|
valueGp: 10,
|
||||||
|
rulesText: "Codex potion result; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.potion-of-aura",
|
||||||
|
name: "Potion of Aura",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: true,
|
||||||
|
valueGp: 15,
|
||||||
|
rulesText: "Codex potion result; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.potion-of-insightful-combat",
|
||||||
|
name: "Potion of Insightful Combat",
|
||||||
|
itemType: "misc",
|
||||||
|
stackable: false,
|
||||||
|
consumable: true,
|
||||||
|
valueGp: 40,
|
||||||
|
rulesText: "Codex potion result; effect automation pending.",
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.padded-tunic",
|
||||||
|
name: "Padded Tunic",
|
||||||
|
itemType: "gear",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 5,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.scale-jacket",
|
||||||
|
name: "Scale Jacket",
|
||||||
|
itemType: "gear",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 12,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.hide-doublet",
|
||||||
|
name: "Hide Doublet",
|
||||||
|
itemType: "gear",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 9,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.bishops-mail",
|
||||||
|
name: "Bishops Mail",
|
||||||
|
itemType: "gear",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 16,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.morning-jacket",
|
||||||
|
name: "Morning Jacket",
|
||||||
|
itemType: "gear",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 7,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.leather-breastplate",
|
||||||
|
name: "Leather Breastplate",
|
||||||
|
itemType: "gear",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 11,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.leather-bracers",
|
||||||
|
name: "Leather Bracers",
|
||||||
|
itemType: "gear",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 8,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.brigandine-coat",
|
||||||
|
name: "Brigandine Coat",
|
||||||
|
itemType: "gear",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 14,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.hide-doublet-alt",
|
||||||
|
name: "Hide Doublet",
|
||||||
|
itemType: "gear",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 9,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "item.woden-shield",
|
||||||
|
name: "Woden Shield",
|
||||||
|
itemType: "gear",
|
||||||
|
stackable: false,
|
||||||
|
consumable: false,
|
||||||
|
valueGp: 10,
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
potions: [
|
potions: [
|
||||||
{
|
{
|
||||||
@@ -133,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: [
|
||||||
{
|
{
|
||||||
@@ -148,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: [
|
||||||
{
|
{
|
||||||
@@ -163,6 +822,7 @@ const samplePack = {
|
|||||||
numberAppearing: "1-2",
|
numberAppearing: "1-2",
|
||||||
},
|
},
|
||||||
xpReward: 1,
|
xpReward: 1,
|
||||||
|
lootTableCodes: ["L1BL"],
|
||||||
sourcePage: 102,
|
sourcePage: 102,
|
||||||
traits: ["level-1", "sample"],
|
traits: ["level-1", "sample"],
|
||||||
mvp: true,
|
mvp: true,
|
||||||
@@ -182,6 +842,7 @@ const samplePack = {
|
|||||||
armour: 1,
|
armour: 1,
|
||||||
},
|
},
|
||||||
xpReward: 2,
|
xpReward: 2,
|
||||||
|
lootTableCodes: ["L1HL"],
|
||||||
sourcePage: 102,
|
sourcePage: 102,
|
||||||
traits: ["level-1", "martial"],
|
traits: ["level-1", "martial"],
|
||||||
mvp: true,
|
mvp: true,
|
||||||
@@ -201,6 +862,7 @@ const samplePack = {
|
|||||||
armour: 1,
|
armour: 1,
|
||||||
},
|
},
|
||||||
xpReward: 3,
|
xpReward: 3,
|
||||||
|
lootTableCodes: ["L1HL"],
|
||||||
sourcePage: 102,
|
sourcePage: 102,
|
||||||
traits: ["level-1", "martial"],
|
traits: ["level-1", "martial"],
|
||||||
mvp: true,
|
mvp: true,
|
||||||
@@ -217,6 +879,7 @@ const samplePack = {
|
|||||||
damage: 1,
|
damage: 1,
|
||||||
},
|
},
|
||||||
xpReward: 1,
|
xpReward: 1,
|
||||||
|
lootTableCodes: ["L1HL"],
|
||||||
sourcePage: 102,
|
sourcePage: 102,
|
||||||
traits: ["level-1", "martial"],
|
traits: ["level-1", "martial"],
|
||||||
mvp: true,
|
mvp: true,
|
||||||
@@ -233,6 +896,7 @@ const samplePack = {
|
|||||||
damage: 1,
|
damage: 1,
|
||||||
},
|
},
|
||||||
xpReward: 1,
|
xpReward: 1,
|
||||||
|
lootTableCodes: ["L1BL"],
|
||||||
sourcePage: 102,
|
sourcePage: 102,
|
||||||
traits: ["level-1", "beast"],
|
traits: ["level-1", "beast"],
|
||||||
mvp: true,
|
mvp: true,
|
||||||
@@ -249,6 +913,7 @@ const samplePack = {
|
|||||||
damage: 1,
|
damage: 1,
|
||||||
},
|
},
|
||||||
xpReward: 2,
|
xpReward: 2,
|
||||||
|
lootTableCodes: ["L1BL"],
|
||||||
sourcePage: 102,
|
sourcePage: 102,
|
||||||
traits: ["level-1", "beast"],
|
traits: ["level-1", "beast"],
|
||||||
mvp: true,
|
mvp: true,
|
||||||
@@ -272,13 +937,31 @@ const samplePack = {
|
|||||||
tags: ["starter", "entry"],
|
tags: ["starter", "entry"],
|
||||||
mvp: true,
|
mvp: true,
|
||||||
},
|
},
|
||||||
...level1RoomTemplates,
|
...level1RoomTemplates.map((template) => ({
|
||||||
|
...template,
|
||||||
|
objects: getLevel1RoomObjects(template.id),
|
||||||
|
})),
|
||||||
],
|
],
|
||||||
townServices: [
|
townServices: [
|
||||||
{
|
{
|
||||||
id: "service.market",
|
id: "service.market",
|
||||||
name: "Market",
|
name: "Market",
|
||||||
serviceType: "market",
|
serviceType: "market",
|
||||||
|
costRules: ["gold:1"],
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "service.healer",
|
||||||
|
name: "Healer",
|
||||||
|
serviceType: "healer",
|
||||||
|
costRules: ["gold:2"],
|
||||||
|
mvp: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "service.tavern",
|
||||||
|
name: "Tavern",
|
||||||
|
serviceType: "tavern",
|
||||||
|
costRules: ["gold:1"],
|
||||||
mvp: true,
|
mvp: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { sampleContentPack } from "@/data/sampleContentPack";
|
||||||
|
|
||||||
|
import { createStartingAdventurer } from "./character";
|
||||||
|
import { createCampaignSession, summarizeRun, syncCampaignFromRun, updateSessionRun } from "./campaign";
|
||||||
|
import { returnToTown } from "./runState";
|
||||||
|
|
||||||
|
function createAdventurer() {
|
||||||
|
return createStartingAdventurer(sampleContentPack, {
|
||||||
|
name: "Aster",
|
||||||
|
weaponId: "weapon.short-sword",
|
||||||
|
armourId: "armour.leather-vest",
|
||||||
|
scrollId: "scroll.lesser-heal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("campaign session", () => {
|
||||||
|
it("creates a synced campaign and run together", () => {
|
||||||
|
const session = createCampaignSession({
|
||||||
|
content: sampleContentPack,
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
campaignId: "campaign.test",
|
||||||
|
at: "2026-03-18T20:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.campaign.id).toBe("campaign.test");
|
||||||
|
expect(session.campaign.adventurer.name).toBe("Aster");
|
||||||
|
expect(session.campaign.runHistory[0]?.runId).toBe(session.run.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs campaign state from an updated run", () => {
|
||||||
|
const session = createCampaignSession({
|
||||||
|
content: sampleContentPack,
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
const nextRun = returnToTown(session.run, "2026-03-18T20:10:00.000Z").run;
|
||||||
|
const synced = syncCampaignFromRun(
|
||||||
|
sampleContentPack,
|
||||||
|
session.campaign,
|
||||||
|
nextRun,
|
||||||
|
"2026-03-18T20:10:00.000Z",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(synced.townState.visits).toBe(1);
|
||||||
|
expect(synced.updatedAt).toBe("2026-03-18T20:10:00.000Z");
|
||||||
|
expect(synced.runHistory[0]?.outcome).toBe("saved-in-progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a session run and campaign together", () => {
|
||||||
|
const session = createCampaignSession({
|
||||||
|
content: sampleContentPack,
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
const updated = updateSessionRun(
|
||||||
|
sampleContentPack,
|
||||||
|
session,
|
||||||
|
returnToTown(session.run, "2026-03-18T20:10:00.000Z").run,
|
||||||
|
"2026-03-18T20:10:00.000Z",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.run.phase).toBe("town");
|
||||||
|
expect(updated.campaign.townState.visits).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promotes completed and unlocked levels from run flags", () => {
|
||||||
|
const session = createCampaignSession({
|
||||||
|
content: sampleContentPack,
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
const nextRun = {
|
||||||
|
...session.run,
|
||||||
|
dungeon: {
|
||||||
|
...session.run.dungeon,
|
||||||
|
globalFlags: ["level:1:completed"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const synced = syncCampaignFromRun(
|
||||||
|
sampleContentPack,
|
||||||
|
session.campaign,
|
||||||
|
nextRun,
|
||||||
|
"2026-03-18T20:15:00.000Z",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(synced.completedLevels).toContain(1);
|
||||||
|
expect(synced.unlockedLevels).toContain(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes failed runs as defeats", () => {
|
||||||
|
const session = createCampaignSession({
|
||||||
|
content: sampleContentPack,
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = summarizeRun({
|
||||||
|
...session.run,
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary.outcome).toBe("defeated");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import type { ContentPack } from "@/types/content";
|
||||||
|
import type { CampaignState, RunState, RunSummary } from "@/types/state";
|
||||||
|
|
||||||
|
import { createRunState } from "./runState";
|
||||||
|
|
||||||
|
export const RULES_VERSION = "0.1.0";
|
||||||
|
|
||||||
|
export type CampaignSession = {
|
||||||
|
campaign: CampaignState;
|
||||||
|
run: RunState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateCampaignSessionOptions = {
|
||||||
|
content: ContentPack;
|
||||||
|
adventurer: CampaignState["adventurer"];
|
||||||
|
at?: string;
|
||||||
|
campaignId?: string;
|
||||||
|
runId?: string;
|
||||||
|
rulesVersion?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function dedupeNumbers(values: number[]) {
|
||||||
|
return [...new Set(values)].sort((left, right) => left - right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompletedLevels(run: RunState) {
|
||||||
|
return run.dungeon.globalFlags
|
||||||
|
.map((flag) => /^level:(\d+):completed$/.exec(flag)?.[1])
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
.map((value) => Number.parseInt(value, 10))
|
||||||
|
.filter((value) => Number.isFinite(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferRunOutcome(run: RunState): RunSummary["outcome"] {
|
||||||
|
if (run.status === "failed") {
|
||||||
|
return "defeated";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.status === "completed") {
|
||||||
|
return "escaped";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "saved-in-progress";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeRun(run: RunState, endedAt?: string): RunSummary {
|
||||||
|
const roomsVisited = Object.values(run.dungeon.levels).reduce(
|
||||||
|
(total, level) =>
|
||||||
|
total + Object.values(level.rooms).filter((room) => room.discovery.entered).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const treasureValue = run.lootedItems.reduce((total, item) => total + item.quantity, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
runId: run.id,
|
||||||
|
startedAt: run.startedAt,
|
||||||
|
endedAt,
|
||||||
|
deepestLevel: run.currentLevel,
|
||||||
|
roomsVisited,
|
||||||
|
creaturesDefeated: [...run.defeatedCreatureIds],
|
||||||
|
xpGained: run.xpGained,
|
||||||
|
treasureValue,
|
||||||
|
outcome: inferRunOutcome(run),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertRunSummary(runHistory: RunSummary[], summary: RunSummary) {
|
||||||
|
const nextHistory = runHistory.filter((entry) => entry.runId !== summary.runId);
|
||||||
|
nextHistory.unshift(summary);
|
||||||
|
return nextHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCampaignFromRun(
|
||||||
|
content: ContentPack,
|
||||||
|
run: RunState,
|
||||||
|
options?: {
|
||||||
|
at?: string;
|
||||||
|
rulesVersion?: string;
|
||||||
|
},
|
||||||
|
): CampaignState {
|
||||||
|
const at = options?.at ?? run.startedAt;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: run.campaignId,
|
||||||
|
createdAt: at,
|
||||||
|
updatedAt: at,
|
||||||
|
rulesVersion: options?.rulesVersion ?? RULES_VERSION,
|
||||||
|
contentVersion: content.version,
|
||||||
|
adventurer: structuredClone(run.adventurerSnapshot),
|
||||||
|
unlockedLevels: [1],
|
||||||
|
completedLevels: [],
|
||||||
|
townState: structuredClone(run.townState),
|
||||||
|
questState: [],
|
||||||
|
campaignFlags: [],
|
||||||
|
runHistory: [summarizeRun(run)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncCampaignFromRun(
|
||||||
|
content: ContentPack,
|
||||||
|
campaign: CampaignState,
|
||||||
|
run: RunState,
|
||||||
|
at = new Date().toISOString(),
|
||||||
|
): CampaignState {
|
||||||
|
const completedLevels = dedupeNumbers([...campaign.completedLevels, ...getCompletedLevels(run)]);
|
||||||
|
const unlockedLevels = dedupeNumbers([
|
||||||
|
...campaign.unlockedLevels,
|
||||||
|
run.currentLevel,
|
||||||
|
...completedLevels,
|
||||||
|
...completedLevels.map((level) => level + 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...structuredClone(campaign),
|
||||||
|
updatedAt: at,
|
||||||
|
contentVersion: content.version,
|
||||||
|
adventurer: structuredClone(run.adventurerSnapshot),
|
||||||
|
townState: structuredClone(run.townState),
|
||||||
|
unlockedLevels,
|
||||||
|
completedLevels,
|
||||||
|
runHistory: upsertRunSummary(campaign.runHistory, summarizeRun(run)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCampaignSession(
|
||||||
|
options: CreateCampaignSessionOptions,
|
||||||
|
): CampaignSession {
|
||||||
|
const run = createRunState({
|
||||||
|
content: options.content,
|
||||||
|
adventurer: options.adventurer,
|
||||||
|
campaignId: options.campaignId ?? "campaign.demo",
|
||||||
|
runId: options.runId,
|
||||||
|
at: options.at,
|
||||||
|
});
|
||||||
|
const campaign = createCampaignFromRun(options.content, run, {
|
||||||
|
at: options.at,
|
||||||
|
rulesVersion: options.rulesVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
campaign,
|
||||||
|
run,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSessionRun(
|
||||||
|
content: ContentPack,
|
||||||
|
session: CampaignSession,
|
||||||
|
nextRun: RunState,
|
||||||
|
at = new Date().toISOString(),
|
||||||
|
): CampaignSession {
|
||||||
|
return {
|
||||||
|
run: nextRun,
|
||||||
|
campaign: syncCampaignFromRun(content, session.campaign, nextRun, at),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -72,7 +72,11 @@ export function createStartingAdventurer(
|
|||||||
throw new Error(`Scroll ${selectedScroll.id} is not a legal starting option.`);
|
throw new Error(`Scroll ${selectedScroll.id} is not a legal starting option.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedManoeuvreIds = selectedWeapon.allowedManoeuvreIds;
|
const allowedManoeuvreIds = selectedWeapon.allowedManoeuvreIds.filter((manoeuvreId) => {
|
||||||
|
const manoeuvre = requireDefinition(content.manoeuvres, manoeuvreId, "manoeuvre");
|
||||||
|
|
||||||
|
return (manoeuvre.minimumLevel ?? 1) <= 1;
|
||||||
|
});
|
||||||
|
|
||||||
if (allowedManoeuvreIds.length === 0) {
|
if (allowedManoeuvreIds.length === 0) {
|
||||||
throw new Error(`Weapon ${selectedWeapon.id} does not define starting manoeuvres.`);
|
throw new Error(`Weapon ${selectedWeapon.id} does not define starting manoeuvres.`);
|
||||||
@@ -119,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],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { sampleContentPack } from "@/data/sampleContentPack";
|
import { sampleContentPack } from "@/data/sampleContentPack";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
addSecretDoorFallback,
|
||||||
expandLevelFromExit,
|
expandLevelFromExit,
|
||||||
getUnresolvedExits,
|
getUnresolvedExits,
|
||||||
initializeDungeonLevel,
|
initializeDungeonLevel,
|
||||||
@@ -84,4 +85,17 @@ describe("dungeon state", () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow("already connected");
|
).toThrow("already connected");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds a fallback secret exit when progression stalls", () => {
|
||||||
|
const levelState = initializeDungeonLevel({ content: sampleContentPack });
|
||||||
|
const room = levelState.rooms["room.level1.start"]!;
|
||||||
|
|
||||||
|
room.exits = [];
|
||||||
|
|
||||||
|
const fallback = addSecretDoorFallback(levelState);
|
||||||
|
|
||||||
|
expect(fallback.levelState.secretDoorUsed).toBe(true);
|
||||||
|
expect(fallback.room.id).toBe("room.level1.start");
|
||||||
|
expect(fallback.exit.exitType).toBe("secret");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ export type ExpansionResult = {
|
|||||||
fromRoom: RoomState;
|
fromRoom: RoomState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlaceStairsDownResult = {
|
||||||
|
levelState: DungeonLevelState;
|
||||||
|
room: RoomState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SecretDoorFallbackResult = {
|
||||||
|
levelState: DungeonLevelState;
|
||||||
|
room: RoomState;
|
||||||
|
exit: RoomExitState;
|
||||||
|
};
|
||||||
|
|
||||||
const DIRECTION_VECTORS: Record<CardinalDirection, { x: number; y: number }> = {
|
const DIRECTION_VECTORS: Record<CardinalDirection, { x: number; y: number }> = {
|
||||||
north: { x: 0, y: -1 },
|
north: { x: 0, y: -1 },
|
||||||
east: { x: 1, y: 0 },
|
east: { x: 1, y: 0 },
|
||||||
@@ -77,6 +88,16 @@ function cloneLevel(levelState: DungeonLevelState): DungeonLevelState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAvailableStairsDirection(room: RoomState): CardinalDirection {
|
||||||
|
const usedDirections = new Set(room.exits.map((exit) => exit.direction));
|
||||||
|
|
||||||
|
return (
|
||||||
|
(["north", "east", "south", "west"] as const).find(
|
||||||
|
(direction) => !usedDirections.has(direction),
|
||||||
|
) ?? "north"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function findExit(room: RoomState, direction: CardinalDirection): RoomExitState {
|
function findExit(room: RoomState, direction: CardinalDirection): RoomExitState {
|
||||||
const exit = room.exits.find((candidate) => candidate.direction === direction);
|
const exit = room.exits.find((candidate) => candidate.direction === direction);
|
||||||
|
|
||||||
@@ -96,6 +117,12 @@ function computeNextPosition(room: RoomState, direction: CardinalDirection) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCoordinateOccupied(levelState: DungeonLevelState, position: { x: number; y: number }) {
|
||||||
|
return Object.values(levelState.rooms).some(
|
||||||
|
(room) => room.position.x === position.x && room.position.y === position.y,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function connectRooms(
|
function connectRooms(
|
||||||
fromRoom: RoomState,
|
fromRoom: RoomState,
|
||||||
toRoom: RoomState,
|
toRoom: RoomState,
|
||||||
@@ -135,6 +162,18 @@ function assertCoordinateAvailable(levelState: DungeonLevelState, position: { x:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLegalNewExitDirections(levelState: DungeonLevelState, room: RoomState) {
|
||||||
|
const usedDirections = new Set(room.exits.map((exit) => exit.direction));
|
||||||
|
|
||||||
|
return (["north", "east", "south", "west"] as const).filter((direction) => {
|
||||||
|
if (usedDirections.has(direction)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isCoordinateOccupied(levelState, computeNextPosition(room, direction));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function initializeDungeonLevel(
|
export function initializeDungeonLevel(
|
||||||
options: InitializeLevelOptions,
|
options: InitializeLevelOptions,
|
||||||
): DungeonLevelState {
|
): DungeonLevelState {
|
||||||
@@ -217,3 +256,126 @@ export function expandLevelFromExit(
|
|||||||
fromRoom,
|
fromRoom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canPlaceStairsDown(
|
||||||
|
levelState: DungeonLevelState,
|
||||||
|
roomId: string,
|
||||||
|
) {
|
||||||
|
const room = levelState.rooms[roomId];
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (levelState.stairsDownRoomId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room.discovery.cleared) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getUnresolvedExits(levelState).length > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !Object.values(levelState.rooms).some(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.id !== roomId &&
|
||||||
|
candidate.roomClass !== "start" &&
|
||||||
|
candidate.roomClass !== "stairs" &&
|
||||||
|
!candidate.discovery.cleared,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placeStairsDown(
|
||||||
|
levelState: DungeonLevelState,
|
||||||
|
roomId: string,
|
||||||
|
): PlaceStairsDownResult {
|
||||||
|
if (!canPlaceStairsDown(levelState, roomId)) {
|
||||||
|
throw new Error(`Cannot place stairs down in room ${roomId}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLevelState = cloneLevel(levelState);
|
||||||
|
const room = nextLevelState.rooms[roomId];
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
throw new Error(`Unknown room id: ${roomId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room.exits.some((exit) => exit.exitType === "stairs")) {
|
||||||
|
room.exits.push({
|
||||||
|
id: `${room.id}.exit.${room.exits.length + 1}`,
|
||||||
|
direction: getAvailableStairsDirection(room),
|
||||||
|
exitType: "stairs",
|
||||||
|
discovered: true,
|
||||||
|
traversable: true,
|
||||||
|
destinationLevel: levelState.level + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room.flags.includes("stairs-down")) {
|
||||||
|
room.flags.push("stairs-down");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room.notes.some((note) => note.includes("stairs"))) {
|
||||||
|
room.notes.push(`A stairway descends toward level ${levelState.level + 1}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextLevelState.rooms[roomId] = room;
|
||||||
|
nextLevelState.stairsDownRoomId = roomId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
levelState: nextLevelState,
|
||||||
|
room,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addSecretDoorFallback(
|
||||||
|
levelState: DungeonLevelState,
|
||||||
|
): SecretDoorFallbackResult {
|
||||||
|
if (levelState.secretDoorUsed) {
|
||||||
|
throw new Error("Secret door fallback has already been used on this level.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLevelState = cloneLevel(levelState);
|
||||||
|
|
||||||
|
for (const roomId of [...nextLevelState.discoveredRoomOrder].reverse()) {
|
||||||
|
const room = nextLevelState.rooms[roomId];
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = getLegalNewExitDirections(nextLevelState, room)[0];
|
||||||
|
|
||||||
|
if (!direction) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exit: RoomExitState = {
|
||||||
|
id: `${room.id}.exit.${room.exits.length + 1}`,
|
||||||
|
direction,
|
||||||
|
exitType: "secret",
|
||||||
|
discovered: true,
|
||||||
|
traversable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
room.exits.push(exit);
|
||||||
|
|
||||||
|
if (!room.flags.includes("fallback-secret-exit")) {
|
||||||
|
room.flags.push("fallback-secret-exit");
|
||||||
|
}
|
||||||
|
|
||||||
|
nextLevelState.rooms[roomId] = room;
|
||||||
|
nextLevelState.secretDoorUsed = true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
levelState: nextLevelState,
|
||||||
|
room,
|
||||||
|
exit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("No eligible room could host a fallback secret door.");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { sampleContentPack } from "@/data/sampleContentPack";
|
||||||
|
|
||||||
|
import { createStartingAdventurer } from "./character";
|
||||||
|
import { startCombatFromRoom } from "./combat";
|
||||||
|
import { resolveCombatLoot } from "./loot";
|
||||||
|
|
||||||
|
function createSequenceRoller(values: number[]) {
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const next = values[index];
|
||||||
|
index += 1;
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAdventurer() {
|
||||||
|
return createStartingAdventurer(sampleContentPack, {
|
||||||
|
name: "Aster",
|
||||||
|
weaponId: "weapon.short-sword",
|
||||||
|
armourId: "armour.leather-vest",
|
||||||
|
scrollId: "scroll.lesser-heal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("loot resolution", () => {
|
||||||
|
it("awards loot from defeated creatures into carried inventory", () => {
|
||||||
|
const room = {
|
||||||
|
id: "room.level1.start",
|
||||||
|
level: 1,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
dimensions: { width: 4, height: 4 },
|
||||||
|
roomClass: "start" as const,
|
||||||
|
exits: [],
|
||||||
|
discovery: {
|
||||||
|
generated: true,
|
||||||
|
entered: true,
|
||||||
|
cleared: false,
|
||||||
|
searched: false,
|
||||||
|
},
|
||||||
|
encounter: {
|
||||||
|
id: "room.level1.start.encounter",
|
||||||
|
sourceTableCode: "L1G",
|
||||||
|
creatureIds: ["room.level1.start.creature.1"],
|
||||||
|
creatureNames: ["Guard"],
|
||||||
|
resultLabel: "Guard",
|
||||||
|
resolved: true,
|
||||||
|
},
|
||||||
|
objects: [],
|
||||||
|
notes: ["Entry Chamber"],
|
||||||
|
flags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const started = startCombatFromRoom({
|
||||||
|
content: sampleContentPack,
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
room,
|
||||||
|
at: "2026-03-15T14:02:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
started.combat.enemies[0]!.hpCurrent = 0;
|
||||||
|
|
||||||
|
const loot = resolveCombatLoot({
|
||||||
|
content: sampleContentPack,
|
||||||
|
combat: started.combat,
|
||||||
|
inventory: createAdventurer().inventory,
|
||||||
|
roller: createSequenceRoller([5]),
|
||||||
|
at: "2026-03-15T14:03:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loot.goldAwarded).toBe(2);
|
||||||
|
expect(loot.itemsAwarded).toEqual([
|
||||||
|
{
|
||||||
|
definitionId: "item.silver-clasp",
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(loot.inventory.currency.gold).toBe(2);
|
||||||
|
expect(loot.inventory.carried).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
definitionId: "item.silver-clasp",
|
||||||
|
quantity: 1,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(loot.logEntries.some((entry) => entry.type === "loot")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { findCreatureById, findItemById, findTableByCode } from "@/data/contentHelpers";
|
||||||
|
import type { ContentPack } from "@/types/content";
|
||||||
|
import type { CombatState, InventoryEntry, InventoryState } from "@/types/state";
|
||||||
|
import type { LogEntry } from "@/types/rules";
|
||||||
|
|
||||||
|
import type { DiceRoller } from "./dice";
|
||||||
|
import { lookupTable } from "./tables";
|
||||||
|
|
||||||
|
export type ResolveCombatLootOptions = {
|
||||||
|
content: ContentPack;
|
||||||
|
combat: CombatState;
|
||||||
|
inventory: InventoryState;
|
||||||
|
roller?: DiceRoller;
|
||||||
|
at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolveCombatLootResult = {
|
||||||
|
inventory: InventoryState;
|
||||||
|
itemsAwarded: InventoryEntry[];
|
||||||
|
goldAwarded: number;
|
||||||
|
logEntries: LogEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function cloneInventory(inventory: InventoryState): InventoryState {
|
||||||
|
return {
|
||||||
|
...inventory,
|
||||||
|
carried: inventory.carried.map((entry) => ({ ...entry })),
|
||||||
|
equipped: inventory.equipped.map((entry) => ({ ...entry })),
|
||||||
|
stored: inventory.stored.map((entry) => ({ ...entry })),
|
||||||
|
currency: { ...inventory.currency },
|
||||||
|
lightSources: inventory.lightSources.map((entry) => ({ ...entry })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInventoryEntry(definitionId: string, quantity = 1): InventoryEntry {
|
||||||
|
return {
|
||||||
|
definitionId,
|
||||||
|
quantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeInventoryEntry(
|
||||||
|
content: ContentPack,
|
||||||
|
entries: InventoryEntry[],
|
||||||
|
definitionId: string,
|
||||||
|
quantity: number,
|
||||||
|
) {
|
||||||
|
const item = findItemById(content, definitionId);
|
||||||
|
|
||||||
|
if (item.stackable) {
|
||||||
|
const existing = entries.find((entry) => entry.definitionId === definitionId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += quantity;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push(createInventoryEntry(definitionId, quantity));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLootLog(
|
||||||
|
id: string,
|
||||||
|
at: string,
|
||||||
|
text: string,
|
||||||
|
relatedIds: string[],
|
||||||
|
): LogEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
at,
|
||||||
|
type: "loot",
|
||||||
|
text,
|
||||||
|
relatedIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeLoot(
|
||||||
|
goldAwarded: number,
|
||||||
|
itemsAwarded: InventoryEntry[],
|
||||||
|
content: ContentPack,
|
||||||
|
) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (goldAwarded > 0) {
|
||||||
|
parts.push(`${goldAwarded} gold`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of itemsAwarded) {
|
||||||
|
const itemName = findItemById(content, item.definitionId).name;
|
||||||
|
parts.push(item.quantity > 1 ? `${item.quantity}x ${itemName}` : itemName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(", ") : "nothing useful";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCombatLoot(
|
||||||
|
options: ResolveCombatLootOptions,
|
||||||
|
): ResolveCombatLootResult {
|
||||||
|
const inventory = cloneInventory(options.inventory);
|
||||||
|
const itemsAwarded: InventoryEntry[] = [];
|
||||||
|
const logEntries: LogEntry[] = [];
|
||||||
|
let goldAwarded = 0;
|
||||||
|
|
||||||
|
const defeatedCreatures = options.combat.enemies.filter(
|
||||||
|
(enemy) => enemy.hpCurrent === 0 && enemy.sourceDefinitionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const enemy of defeatedCreatures) {
|
||||||
|
const creature = findCreatureById(options.content, enemy.sourceDefinitionId!);
|
||||||
|
const lootTableCodes = creature.lootTableCodes ?? [];
|
||||||
|
|
||||||
|
for (const tableCode of lootTableCodes) {
|
||||||
|
const table = findTableByCode(options.content, tableCode);
|
||||||
|
const lookup = lookupTable(table, { roller: options.roller });
|
||||||
|
const rollTotal = lookup.roll.modifiedTotal ?? lookup.roll.total;
|
||||||
|
|
||||||
|
logEntries.push({
|
||||||
|
id: `${options.combat.id}.${creature.id}.${tableCode}.roll`,
|
||||||
|
at: options.at,
|
||||||
|
type: "roll",
|
||||||
|
text: `Rolled loot ${lookup.roll.diceKind} [${lookup.roll.rolls.join(", ")}] on ${tableCode} for ${rollTotal}: ${lookup.entry.label}.`,
|
||||||
|
relatedIds: [options.combat.id, creature.id, tableCode],
|
||||||
|
});
|
||||||
|
|
||||||
|
let creatureGold = 0;
|
||||||
|
const creatureItems: InventoryEntry[] = [];
|
||||||
|
|
||||||
|
for (const effect of lookup.entry.effects ?? []) {
|
||||||
|
if (effect.type === "gain-gold" && effect.amount) {
|
||||||
|
creatureGold += effect.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effect.type === "add-item" && effect.referenceId) {
|
||||||
|
mergeInventoryEntry(options.content, inventory.carried, effect.referenceId, effect.amount ?? 1);
|
||||||
|
mergeInventoryEntry(options.content, itemsAwarded, effect.referenceId, effect.amount ?? 1);
|
||||||
|
mergeInventoryEntry(options.content, creatureItems, effect.referenceId, effect.amount ?? 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const reference of lookup.entry.references ?? []) {
|
||||||
|
if (reference.type !== "item") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeInventoryEntry(options.content, inventory.carried, reference.id, 1);
|
||||||
|
mergeInventoryEntry(options.content, itemsAwarded, reference.id, 1);
|
||||||
|
mergeInventoryEntry(options.content, creatureItems, reference.id, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creatureGold > 0) {
|
||||||
|
inventory.currency.gold += creatureGold;
|
||||||
|
goldAwarded += creatureGold;
|
||||||
|
}
|
||||||
|
|
||||||
|
logEntries.push(
|
||||||
|
createLootLog(
|
||||||
|
`${options.combat.id}.${creature.id}.${tableCode}.loot`,
|
||||||
|
options.at,
|
||||||
|
`${creature.name} yielded ${summarizeLoot(creatureGold, creatureItems, options.content)}.`,
|
||||||
|
[options.combat.id, creature.id, tableCode],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
inventory,
|
||||||
|
itemsAwarded,
|
||||||
|
goldAwarded,
|
||||||
|
logEntries,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,160 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { sampleContentPack } from "@/data/sampleContentPack";
|
||||||
|
|
||||||
|
import { createStartingAdventurer } from "./character";
|
||||||
|
import { createCampaignSession } from "./campaign";
|
||||||
|
import {
|
||||||
|
deleteSavedCampaignSession,
|
||||||
|
deleteSavedRun,
|
||||||
|
exportCampaignSession,
|
||||||
|
importCampaignSession,
|
||||||
|
listSavedCampaigns,
|
||||||
|
loadSavedRun,
|
||||||
|
loadSavedCampaignSession,
|
||||||
|
saveRun,
|
||||||
|
listSavedRuns,
|
||||||
|
saveCampaignSession,
|
||||||
|
type StorageLike,
|
||||||
|
} from "./persistence";
|
||||||
|
import { createRunState, returnToTown } from "./runState";
|
||||||
|
|
||||||
|
function createMemoryStorage(): StorageLike {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
getItem(key) {
|
||||||
|
return values.get(key) ?? null;
|
||||||
|
},
|
||||||
|
setItem(key, value) {
|
||||||
|
values.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem(key) {
|
||||||
|
values.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAdventurer() {
|
||||||
|
return createStartingAdventurer(sampleContentPack, {
|
||||||
|
name: "Aster",
|
||||||
|
weaponId: "weapon.short-sword",
|
||||||
|
armourId: "armour.leather-vest",
|
||||||
|
scrollId: "scroll.lesser-heal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("run persistence", () => {
|
||||||
|
it("saves and lists runs with newest first", () => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
saveRun(storage, run, {
|
||||||
|
saveId: "save.one",
|
||||||
|
savedAt: "2026-03-18T23:00:00.000Z",
|
||||||
|
});
|
||||||
|
saveRun(storage, returnToTown(run).run, {
|
||||||
|
saveId: "save.two",
|
||||||
|
savedAt: "2026-03-18T23:10:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const saves = listSavedRuns(storage);
|
||||||
|
|
||||||
|
expect(saves).toHaveLength(2);
|
||||||
|
expect(saves[0]?.id).toBe("save.two");
|
||||||
|
expect(saves[0]?.phase).toBe("town");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads a saved run back into state", () => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
saveRun(storage, run, {
|
||||||
|
saveId: "save.one",
|
||||||
|
savedAt: "2026-03-18T23:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = loadSavedRun(storage, "save.one");
|
||||||
|
|
||||||
|
expect(loaded.currentRoomId).toBe(run.currentRoomId);
|
||||||
|
expect(loaded.adventurerSnapshot.name).toBe("Aster");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes saved runs", () => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
saveRun(storage, run, {
|
||||||
|
saveId: "save.one",
|
||||||
|
savedAt: "2026-03-18T23:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const remaining = deleteSavedRun(storage, "save.one");
|
||||||
|
|
||||||
|
expect(remaining).toEqual([]);
|
||||||
|
expect(listSavedRuns(storage)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("campaign persistence", () => {
|
||||||
|
it("saves and loads a campaign session", () => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
const session = createCampaignSession({
|
||||||
|
content: sampleContentPack,
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
at: "2026-03-18T23:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
saveCampaignSession(storage, session, {
|
||||||
|
saveId: "campaign.one",
|
||||||
|
savedAt: "2026-03-18T23:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = loadSavedCampaignSession(storage, "campaign.one");
|
||||||
|
|
||||||
|
expect(loaded.campaign.id).toBe("campaign.1");
|
||||||
|
expect(loaded.run.adventurerSnapshot.name).toBe("Aster");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists and deletes campaign saves", () => {
|
||||||
|
const storage = createMemoryStorage();
|
||||||
|
const session = createCampaignSession({
|
||||||
|
content: sampleContentPack,
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
saveCampaignSession(storage, session, {
|
||||||
|
saveId: "campaign.one",
|
||||||
|
savedAt: "2026-03-18T23:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listSavedCampaigns(storage)).toHaveLength(1);
|
||||||
|
expect(deleteSavedCampaignSession(storage, "campaign.one")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports and imports campaign json", () => {
|
||||||
|
const session = createCampaignSession({
|
||||||
|
content: sampleContentPack,
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const exported = exportCampaignSession(session);
|
||||||
|
const imported = importCampaignSession(exported);
|
||||||
|
|
||||||
|
expect(imported.campaign.id).toBe(session.campaign.id);
|
||||||
|
expect(imported.run.id).toBe(session.run.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { campaignStateSchema, runStateSchema } from "@/schemas/state";
|
||||||
|
import type { RunState } from "@/types/state";
|
||||||
|
import type { CampaignSession } from "./campaign";
|
||||||
|
|
||||||
|
export type StorageLike = {
|
||||||
|
getItem(key: string): string | null;
|
||||||
|
setItem(key: string, value: string): void;
|
||||||
|
removeItem(key: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SavedRunRecord = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
savedAt: string;
|
||||||
|
run: RunState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SavedRunSummary = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
savedAt: string;
|
||||||
|
phase: RunState["phase"];
|
||||||
|
currentLevel: number;
|
||||||
|
currentRoomId?: string;
|
||||||
|
adventurerName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SavedCampaignRecord = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
savedAt: string;
|
||||||
|
session: CampaignSession;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SavedCampaignSummary = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
savedAt: string;
|
||||||
|
campaignId: string;
|
||||||
|
adventurerName: string;
|
||||||
|
currentLevel: number;
|
||||||
|
phase: RunState["phase"];
|
||||||
|
visits: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = "d2d6-dungeon.run-saves.v1";
|
||||||
|
const CAMPAIGN_STORAGE_KEY = "d2d6-dungeon.campaign-saves.v1";
|
||||||
|
|
||||||
|
const savedRunRecordSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
savedAt: z.string().min(1),
|
||||||
|
run: runStateSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedRunRecordListSchema = z.array(savedRunRecordSchema);
|
||||||
|
const savedCampaignRecordSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
savedAt: z.string().min(1),
|
||||||
|
session: z.object({
|
||||||
|
campaign: campaignStateSchema,
|
||||||
|
run: runStateSchema,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const savedCampaignRecordListSchema = z.array(savedCampaignRecordSchema);
|
||||||
|
|
||||||
|
function readSaveRecords(storage: StorageLike): SavedRunRecord[] {
|
||||||
|
const raw = storage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return savedRunRecordListSchema.parse(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSaveRecords(storage: StorageLike, records: SavedRunRecord[]) {
|
||||||
|
storage.setItem(STORAGE_KEY, JSON.stringify(records));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCampaignRecords(storage: StorageLike): SavedCampaignRecord[] {
|
||||||
|
const raw = storage.getItem(CAMPAIGN_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return savedCampaignRecordListSchema.parse(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCampaignRecords(storage: StorageLike, records: SavedCampaignRecord[]) {
|
||||||
|
storage.setItem(CAMPAIGN_STORAGE_KEY, JSON.stringify(records));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSummary(record: SavedRunRecord): SavedRunSummary {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
label: record.label,
|
||||||
|
savedAt: record.savedAt,
|
||||||
|
phase: record.run.phase,
|
||||||
|
currentLevel: record.run.currentLevel,
|
||||||
|
currentRoomId: record.run.currentRoomId,
|
||||||
|
adventurerName: record.run.adventurerSnapshot.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCampaignSummary(record: SavedCampaignRecord): SavedCampaignSummary {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
label: record.label,
|
||||||
|
savedAt: record.savedAt,
|
||||||
|
campaignId: record.session.campaign.id,
|
||||||
|
adventurerName: record.session.campaign.adventurer.name,
|
||||||
|
currentLevel: record.session.run.currentLevel,
|
||||||
|
phase: record.session.run.phase,
|
||||||
|
visits: record.session.campaign.townState.visits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSaveLabel(run: RunState) {
|
||||||
|
const roomLabel = run.currentRoomId ?? "unknown-room";
|
||||||
|
return `${run.adventurerSnapshot.name} · L${run.currentLevel} · ${run.phase} · ${roomLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCampaignSaveLabel(session: CampaignSession) {
|
||||||
|
return `${session.campaign.adventurer.name} · L${session.run.currentLevel} · ${session.run.phase} · ${session.campaign.runHistory.length} log`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSavedRuns(storage: StorageLike): SavedRunSummary[] {
|
||||||
|
return readSaveRecords(storage)
|
||||||
|
.sort((left, right) => right.savedAt.localeCompare(left.savedAt))
|
||||||
|
.map(toSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveRun(
|
||||||
|
storage: StorageLike,
|
||||||
|
run: RunState,
|
||||||
|
options?: {
|
||||||
|
saveId?: string;
|
||||||
|
label?: string;
|
||||||
|
savedAt?: string;
|
||||||
|
},
|
||||||
|
): SavedRunSummary {
|
||||||
|
const savedAt = options?.savedAt ?? new Date().toISOString();
|
||||||
|
const id = options?.saveId ?? `save.${savedAt}`;
|
||||||
|
const label = options?.label ?? buildSaveLabel(run);
|
||||||
|
const record = savedRunRecordSchema.parse({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
savedAt,
|
||||||
|
run,
|
||||||
|
});
|
||||||
|
const existing = readSaveRecords(storage).filter((entry) => entry.id !== id);
|
||||||
|
|
||||||
|
existing.unshift(record);
|
||||||
|
writeSaveRecords(storage, existing);
|
||||||
|
|
||||||
|
return toSummary(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSavedRun(storage: StorageLike, saveId: string): RunState {
|
||||||
|
const record = readSaveRecords(storage).find((entry) => entry.id === saveId);
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error(`Unknown save id: ${saveId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return record.run;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSavedRun(storage: StorageLike, saveId: string): SavedRunSummary[] {
|
||||||
|
const records = readSaveRecords(storage).filter((entry) => entry.id !== saveId);
|
||||||
|
|
||||||
|
writeSaveRecords(storage, records);
|
||||||
|
|
||||||
|
return records
|
||||||
|
.sort((left, right) => right.savedAt.localeCompare(left.savedAt))
|
||||||
|
.map(toSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBrowserStorage(): StorageLike | null {
|
||||||
|
if (typeof window === "undefined" || !window.localStorage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.localStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSavedCampaigns(storage: StorageLike): SavedCampaignSummary[] {
|
||||||
|
return readCampaignRecords(storage)
|
||||||
|
.sort((left, right) => right.savedAt.localeCompare(left.savedAt))
|
||||||
|
.map(toCampaignSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCampaignSession(
|
||||||
|
storage: StorageLike,
|
||||||
|
session: CampaignSession,
|
||||||
|
options?: {
|
||||||
|
saveId?: string;
|
||||||
|
label?: string;
|
||||||
|
savedAt?: string;
|
||||||
|
},
|
||||||
|
): SavedCampaignSummary {
|
||||||
|
const savedAt = options?.savedAt ?? new Date().toISOString();
|
||||||
|
const id = options?.saveId ?? `campaign-save.${savedAt}`;
|
||||||
|
const label = options?.label ?? buildCampaignSaveLabel(session);
|
||||||
|
const record = savedCampaignRecordSchema.parse({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
savedAt,
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
const existing = readCampaignRecords(storage).filter((entry) => entry.id !== id);
|
||||||
|
|
||||||
|
existing.unshift(record);
|
||||||
|
writeCampaignRecords(storage, existing);
|
||||||
|
|
||||||
|
return toCampaignSummary(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSavedCampaignSession(storage: StorageLike, saveId: string): CampaignSession {
|
||||||
|
const record = readCampaignRecords(storage).find((entry) => entry.id === saveId);
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error(`Unknown campaign save id: ${saveId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return record.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSavedCampaignSession(
|
||||||
|
storage: StorageLike,
|
||||||
|
saveId: string,
|
||||||
|
): SavedCampaignSummary[] {
|
||||||
|
const records = readCampaignRecords(storage).filter((entry) => entry.id !== saveId);
|
||||||
|
|
||||||
|
writeCampaignRecords(storage, records);
|
||||||
|
|
||||||
|
return records
|
||||||
|
.sort((left, right) => right.savedAt.localeCompare(left.savedAt))
|
||||||
|
.map(toCampaignSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportCampaignSession(session: CampaignSession) {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
session,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importCampaignSession(serialized: string): CampaignSession {
|
||||||
|
const parsed = JSON.parse(serialized) as unknown;
|
||||||
|
const importSchema = z.object({
|
||||||
|
exportedAt: z.string().min(1),
|
||||||
|
session: z.object({
|
||||||
|
campaign: campaignStateSchema,
|
||||||
|
run: runStateSchema,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return importSchema.parse(parsed).session;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { sampleContentPack } from "@/data/sampleContentPack";
|
||||||
|
|
||||||
|
import { createStartingAdventurer } from "./character";
|
||||||
|
import {
|
||||||
|
applyLevelProgression,
|
||||||
|
getLevelForXp,
|
||||||
|
getNextLevelXpThreshold,
|
||||||
|
getXpThresholdForLevel,
|
||||||
|
} from "./progression";
|
||||||
|
|
||||||
|
function createAdventurer() {
|
||||||
|
return createStartingAdventurer(sampleContentPack, {
|
||||||
|
name: "Aster",
|
||||||
|
weaponId: "weapon.short-sword",
|
||||||
|
armourId: "armour.leather-vest",
|
||||||
|
scrollId: "scroll.lesser-heal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("level progression rules", () => {
|
||||||
|
it("uses linear xp thresholds for the current MVP ruleset", () => {
|
||||||
|
expect(getXpThresholdForLevel(1)).toBe(0);
|
||||||
|
expect(getXpThresholdForLevel(2)).toBe(8);
|
||||||
|
expect(getXpThresholdForLevel(3)).toBe(16);
|
||||||
|
expect(getNextLevelXpThreshold(1)).toBe(8);
|
||||||
|
expect(getLevelForXp(0)).toBe(1);
|
||||||
|
expect(getLevelForXp(8)).toBe(2);
|
||||||
|
expect(getLevelForXp(16)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("levels up immediately once xp crosses a threshold", () => {
|
||||||
|
const adventurer = createAdventurer();
|
||||||
|
adventurer.xp = 8;
|
||||||
|
adventurer.hp.current = 7;
|
||||||
|
|
||||||
|
const result = applyLevelProgression({
|
||||||
|
content: sampleContentPack,
|
||||||
|
adventurer,
|
||||||
|
at: "2026-03-18T10:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.adventurer.level).toBe(2);
|
||||||
|
expect(result.adventurer.hp.max).toBe(12);
|
||||||
|
expect(result.adventurer.hp.current).toBe(9);
|
||||||
|
expect(result.adventurer.manoeuvreIds).toContain("manoeuvre.sweeping-cut");
|
||||||
|
expect(result.levelUps).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
previousLevel: 1,
|
||||||
|
newLevel: 2,
|
||||||
|
hpGained: 2,
|
||||||
|
unlockedManoeuvreIds: ["manoeuvre.sweeping-cut"],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the adventurer unchanged when no threshold is crossed", () => {
|
||||||
|
const adventurer = createAdventurer();
|
||||||
|
adventurer.xp = 7;
|
||||||
|
|
||||||
|
const result = applyLevelProgression({
|
||||||
|
content: sampleContentPack,
|
||||||
|
adventurer,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.adventurer.level).toBe(1);
|
||||||
|
expect(result.adventurer.hp.max).toBe(10);
|
||||||
|
expect(result.levelUps).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { ContentPack } from "@/types/content";
|
||||||
|
import type { AdventurerState, LevelUpState } from "@/types/state";
|
||||||
|
|
||||||
|
export const XP_PER_LEVEL = 8;
|
||||||
|
export const HP_GAIN_PER_LEVEL = 2;
|
||||||
|
export const MAX_ADVENTURER_LEVEL = 10;
|
||||||
|
|
||||||
|
export type ApplyLevelProgressionOptions = {
|
||||||
|
content: ContentPack;
|
||||||
|
adventurer: AdventurerState;
|
||||||
|
at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LevelProgressionResult = {
|
||||||
|
adventurer: AdventurerState;
|
||||||
|
levelUps: LevelUpState[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getXpThresholdForLevel(level: number) {
|
||||||
|
if (level <= 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (level - 1) * XP_PER_LEVEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextLevelXpThreshold(level: number) {
|
||||||
|
return getXpThresholdForLevel(level + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLevelForXp(xp: number) {
|
||||||
|
if (xp < 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(MAX_ADVENTURER_LEVEL, Math.floor(xp / XP_PER_LEVEL) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUnlockedWeaponManoeuvreIds(content: ContentPack, adventurer: AdventurerState, level: number) {
|
||||||
|
const weapon = content.weapons.find((entry) => entry.id === adventurer.weaponId);
|
||||||
|
|
||||||
|
if (!weapon) {
|
||||||
|
throw new Error(`Unknown weapon id: ${adventurer.weaponId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return weapon.allowedManoeuvreIds.filter((manoeuvreId) => {
|
||||||
|
const manoeuvre = content.manoeuvres.find((entry) => entry.id === manoeuvreId);
|
||||||
|
|
||||||
|
if (!manoeuvre) {
|
||||||
|
throw new Error(`Unknown manoeuvre id: ${manoeuvreId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (manoeuvre.minimumLevel ?? 1) <= level;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyLevelProgression(
|
||||||
|
options: ApplyLevelProgressionOptions,
|
||||||
|
): LevelProgressionResult {
|
||||||
|
const nextAdventurer: AdventurerState = {
|
||||||
|
...options.adventurer,
|
||||||
|
hp: { ...options.adventurer.hp },
|
||||||
|
stats: { ...options.adventurer.stats },
|
||||||
|
favour: { ...options.adventurer.favour },
|
||||||
|
statuses: options.adventurer.statuses.map((status) => ({ ...status })),
|
||||||
|
inventory: {
|
||||||
|
carried: options.adventurer.inventory.carried.map((entry) => ({ ...entry })),
|
||||||
|
equipped: options.adventurer.inventory.equipped.map((entry) => ({ ...entry })),
|
||||||
|
stored: options.adventurer.inventory.stored.map((entry) => ({ ...entry })),
|
||||||
|
currency: { ...options.adventurer.inventory.currency },
|
||||||
|
rationCount: options.adventurer.inventory.rationCount,
|
||||||
|
lightSources: options.adventurer.inventory.lightSources.map((entry) => ({ ...entry })),
|
||||||
|
},
|
||||||
|
progressionFlags: [...options.adventurer.progressionFlags],
|
||||||
|
manoeuvreIds: [...options.adventurer.manoeuvreIds],
|
||||||
|
};
|
||||||
|
const targetLevel = getLevelForXp(nextAdventurer.xp);
|
||||||
|
const at = options.at ?? new Date().toISOString();
|
||||||
|
const levelUps: LevelUpState[] = [];
|
||||||
|
|
||||||
|
while (nextAdventurer.level < targetLevel) {
|
||||||
|
const previousLevel = nextAdventurer.level;
|
||||||
|
const newLevel = previousLevel + 1;
|
||||||
|
|
||||||
|
nextAdventurer.level = newLevel;
|
||||||
|
nextAdventurer.hp.max += HP_GAIN_PER_LEVEL;
|
||||||
|
nextAdventurer.hp.current = Math.min(
|
||||||
|
nextAdventurer.hp.max,
|
||||||
|
nextAdventurer.hp.current + HP_GAIN_PER_LEVEL,
|
||||||
|
);
|
||||||
|
|
||||||
|
const unlockedManoeuvreIds = getUnlockedWeaponManoeuvreIds(
|
||||||
|
options.content,
|
||||||
|
nextAdventurer,
|
||||||
|
newLevel,
|
||||||
|
).filter((manoeuvreId) => !nextAdventurer.manoeuvreIds.includes(manoeuvreId));
|
||||||
|
|
||||||
|
nextAdventurer.manoeuvreIds.push(...unlockedManoeuvreIds);
|
||||||
|
|
||||||
|
const levelFlag = `level.reached.${newLevel}`;
|
||||||
|
if (!nextAdventurer.progressionFlags.includes(levelFlag)) {
|
||||||
|
nextAdventurer.progressionFlags.push(levelFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
levelUps.push({
|
||||||
|
previousLevel,
|
||||||
|
newLevel,
|
||||||
|
at,
|
||||||
|
hpGained: HP_GAIN_PER_LEVEL,
|
||||||
|
unlockedManoeuvreIds,
|
||||||
|
summary:
|
||||||
|
unlockedManoeuvreIds.length > 0
|
||||||
|
? `Reached level ${newLevel}, gained ${HP_GAIN_PER_LEVEL} max HP, and unlocked ${unlockedManoeuvreIds.length} manoeuvre${unlockedManoeuvreIds.length === 1 ? "" : "s"}.`
|
||||||
|
: `Reached level ${newLevel} and gained ${HP_GAIN_PER_LEVEL} max HP.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
adventurer: nextAdventurer,
|
||||||
|
levelUps,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { sampleContentPack } from "@/data/sampleContentPack";
|
||||||
|
|
||||||
|
import { createStartingAdventurer } from "./character";
|
||||||
|
import { createRunState, returnToTown } from "./runState";
|
||||||
|
import { getConsumableCounts, restWithRation, usePotion, useScroll } from "./recovery";
|
||||||
|
|
||||||
|
function createAdventurer() {
|
||||||
|
return createStartingAdventurer(sampleContentPack, {
|
||||||
|
name: "Aster",
|
||||||
|
weaponId: "weapon.short-sword",
|
||||||
|
armourId: "armour.leather-vest",
|
||||||
|
scrollId: "scroll.lesser-heal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("recovery and consumables", () => {
|
||||||
|
it("uses a healing potion and restores hp", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
run.adventurerSnapshot.hp.current = 6;
|
||||||
|
|
||||||
|
const result = usePotion({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run,
|
||||||
|
definitionId: "potion.healing",
|
||||||
|
at: "2026-03-18T22:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.run.adventurerSnapshot.hp.current).toBe(9);
|
||||||
|
expect(getConsumableCounts(result.run).healingPotion).toBe(0);
|
||||||
|
expect(result.run.log.at(-1)?.text).toContain("recovered 3 HP");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("casts a healing scroll and consumes it on success", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
run.adventurerSnapshot.hp.current = 7;
|
||||||
|
|
||||||
|
const result = useScroll({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run,
|
||||||
|
definitionId: "scroll.lesser-heal",
|
||||||
|
roller: () => 5,
|
||||||
|
at: "2026-03-18T22:05:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.run.adventurerSnapshot.hp.current).toBe(9);
|
||||||
|
expect(getConsumableCounts(result.run).lesserHealScroll).toBe(0);
|
||||||
|
expect(result.run.log.at(-1)?.text).toContain("roll 5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a ration in town to recover hp and reduce rations", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
run.adventurerSnapshot.hp.current = 5;
|
||||||
|
const inTown = returnToTown(run, "2026-03-18T22:10:00.000Z").run;
|
||||||
|
const result = restWithRation({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run: inTown,
|
||||||
|
definitionId: "item.ration",
|
||||||
|
at: "2026-03-18T22:12:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.run.adventurerSnapshot.hp.current).toBe(7);
|
||||||
|
expect(result.run.adventurerSnapshot.inventory.rationCount).toBe(2);
|
||||||
|
expect(getConsumableCounts(result.run).ration).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import {
|
||||||
|
findItemById,
|
||||||
|
findPotionById,
|
||||||
|
findScrollById,
|
||||||
|
} from "@/data/contentHelpers";
|
||||||
|
import type { ContentPack, PotionDefinition } from "@/types/content";
|
||||||
|
import type { InventoryEntry, RunState } from "@/types/state";
|
||||||
|
import type { LogEntry } from "@/types/rules";
|
||||||
|
|
||||||
|
import type { DiceRoller } from "./dice";
|
||||||
|
|
||||||
|
export type UseRecoveryResourceOptions = {
|
||||||
|
content: ContentPack;
|
||||||
|
run: RunState;
|
||||||
|
definitionId: string;
|
||||||
|
roller?: DiceRoller;
|
||||||
|
at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecoveryActionResult = {
|
||||||
|
run: RunState;
|
||||||
|
logEntries: LogEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function cloneRun(run: RunState): RunState {
|
||||||
|
return {
|
||||||
|
...run,
|
||||||
|
adventurerSnapshot: {
|
||||||
|
...run.adventurerSnapshot,
|
||||||
|
hp: { ...run.adventurerSnapshot.hp },
|
||||||
|
stats: { ...run.adventurerSnapshot.stats },
|
||||||
|
favour: { ...run.adventurerSnapshot.favour },
|
||||||
|
statuses: run.adventurerSnapshot.statuses.map((status) => ({ ...status })),
|
||||||
|
inventory: {
|
||||||
|
...run.adventurerSnapshot.inventory,
|
||||||
|
carried: run.adventurerSnapshot.inventory.carried.map((entry) => ({ ...entry })),
|
||||||
|
equipped: run.adventurerSnapshot.inventory.equipped.map((entry) => ({ ...entry })),
|
||||||
|
stored: run.adventurerSnapshot.inventory.stored.map((entry) => ({ ...entry })),
|
||||||
|
currency: { ...run.adventurerSnapshot.inventory.currency },
|
||||||
|
lightSources: run.adventurerSnapshot.inventory.lightSources.map((entry) => ({ ...entry })),
|
||||||
|
},
|
||||||
|
progressionFlags: [...run.adventurerSnapshot.progressionFlags],
|
||||||
|
manoeuvreIds: [...run.adventurerSnapshot.manoeuvreIds],
|
||||||
|
},
|
||||||
|
townState: {
|
||||||
|
...run.townState,
|
||||||
|
knownServices: [...run.townState.knownServices],
|
||||||
|
stash: run.townState.stash.map((entry) => ({ ...entry })),
|
||||||
|
pendingSales: run.townState.pendingSales.map((entry) => ({ ...entry })),
|
||||||
|
serviceFlags: [...run.townState.serviceFlags],
|
||||||
|
},
|
||||||
|
activeCombat: run.activeCombat
|
||||||
|
? {
|
||||||
|
...run.activeCombat,
|
||||||
|
player: {
|
||||||
|
...run.activeCombat.player,
|
||||||
|
statuses: [...run.activeCombat.player.statuses],
|
||||||
|
traits: [...run.activeCombat.player.traits],
|
||||||
|
},
|
||||||
|
enemies: run.activeCombat.enemies.map((enemy) => ({
|
||||||
|
...enemy,
|
||||||
|
statuses: [...enemy.statuses],
|
||||||
|
traits: [...enemy.traits],
|
||||||
|
})),
|
||||||
|
combatLog: run.activeCombat.combatLog.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
defeatedCreatureIds: [...run.defeatedCreatureIds],
|
||||||
|
log: run.log.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined,
|
||||||
|
})),
|
||||||
|
pendingEffects: run.pendingEffects.map((effect) => ({ ...effect })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCarriedEntry(entries: InventoryEntry[], definitionId: string) {
|
||||||
|
return entries.find((entry) => entry.definitionId === definitionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeCarriedEntry(entries: InventoryEntry[], definitionId: string, quantity = 1) {
|
||||||
|
const existing = findCarriedEntry(entries, 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 = entries.indexOf(existing);
|
||||||
|
entries.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function healAdventurer(run: RunState, amount: number) {
|
||||||
|
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 = current + healed;
|
||||||
|
|
||||||
|
if (run.activeCombat) {
|
||||||
|
run.activeCombat.player.hpCurrent = run.adventurerSnapshot.hp.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return healed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLogEntry(
|
||||||
|
id: string,
|
||||||
|
at: string,
|
||||||
|
text: string,
|
||||||
|
relatedIds: string[],
|
||||||
|
): LogEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
at,
|
||||||
|
type: "progression",
|
||||||
|
text,
|
||||||
|
relatedIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUsePotion(run: RunState, potion: PotionDefinition) {
|
||||||
|
if (run.activeCombat) {
|
||||||
|
return potion.useTiming === "combat" || potion.useTiming === "any";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.phase === "town") {
|
||||||
|
return potion.useTiming === "town" || potion.useTiming === "any";
|
||||||
|
}
|
||||||
|
|
||||||
|
return potion.useTiming === "exploration" || potion.useTiming === "any";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHealEffects(run: RunState, effects: { type: string; amount?: number }[]) {
|
||||||
|
return effects.reduce((total, effect) => {
|
||||||
|
if (effect.type !== "heal") {
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
return total + healAdventurer(run, effect.amount ?? 0);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePotion(
|
||||||
|
options: UseRecoveryResourceOptions,
|
||||||
|
): RecoveryActionResult {
|
||||||
|
const run = cloneRun(options.run);
|
||||||
|
const potion = findPotionById(options.content, options.definitionId);
|
||||||
|
|
||||||
|
if (!canUsePotion(run, potion)) {
|
||||||
|
throw new Error(`${potion.name} cannot be used in the current phase.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
consumeCarriedEntry(run.adventurerSnapshot.inventory.carried, potion.id);
|
||||||
|
|
||||||
|
const healed = applyHealEffects(run, potion.effects);
|
||||||
|
const at = options.at ?? new Date().toISOString();
|
||||||
|
const phaseLabel = run.phase === "town" ? "in town" : run.activeCombat ? "during combat" : "while exploring";
|
||||||
|
const logEntry = createLogEntry(
|
||||||
|
`recovery.potion.${potion.id}.${run.log.length + 1}`,
|
||||||
|
at,
|
||||||
|
`Used ${potion.name} ${phaseLabel} and recovered ${healed} HP.`,
|
||||||
|
[potion.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
run.log.push(logEntry);
|
||||||
|
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
logEntries: [logEntry],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScroll(
|
||||||
|
options: UseRecoveryResourceOptions,
|
||||||
|
): RecoveryActionResult {
|
||||||
|
const run = cloneRun(options.run);
|
||||||
|
const scroll = findScrollById(options.content, options.definitionId);
|
||||||
|
const carriedEntry = findCarriedEntry(run.adventurerSnapshot.inventory.carried, scroll.id);
|
||||||
|
|
||||||
|
if (!carriedEntry) {
|
||||||
|
throw new Error(`No carried ${scroll.name} is available to use.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = options.at ?? new Date().toISOString();
|
||||||
|
const roll = scroll.castCheck ? (options.roller ?? (() => 1))(6) : undefined;
|
||||||
|
const succeeded =
|
||||||
|
!scroll.castCheck ||
|
||||||
|
((scroll.castCheck.successMin === undefined || roll! >= scroll.castCheck.successMin) &&
|
||||||
|
(scroll.castCheck.successMax === undefined || roll! <= scroll.castCheck.successMax));
|
||||||
|
|
||||||
|
consumeCarriedEntry(run.adventurerSnapshot.inventory.carried, scroll.id);
|
||||||
|
|
||||||
|
const healed = succeeded ? applyHealEffects(run, scroll.onSuccess) : 0;
|
||||||
|
const rollText = scroll.castCheck ? ` (roll ${roll})` : "";
|
||||||
|
const outcomeText = succeeded
|
||||||
|
? `Cast ${scroll.name}${rollText} and recovered ${healed} HP.`
|
||||||
|
: `Cast ${scroll.name}${rollText}, but the spell failed.`;
|
||||||
|
const logEntry = createLogEntry(
|
||||||
|
`recovery.scroll.${scroll.id}.${run.log.length + 1}`,
|
||||||
|
at,
|
||||||
|
outcomeText,
|
||||||
|
[scroll.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
run.log.push(logEntry);
|
||||||
|
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
logEntries: [logEntry],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restWithRation(
|
||||||
|
options: UseRecoveryResourceOptions,
|
||||||
|
): RecoveryActionResult {
|
||||||
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
if (run.phase !== "town") {
|
||||||
|
throw new Error("Ration rest is only available while in town.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.activeCombat) {
|
||||||
|
throw new Error("Cannot rest with a ration during active combat.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ration = findItemById(options.content, "item.ration");
|
||||||
|
|
||||||
|
if (ration.itemType !== "ration") {
|
||||||
|
throw new Error("Configured ration item is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
consumeCarriedEntry(run.adventurerSnapshot.inventory.carried, ration.id);
|
||||||
|
run.adventurerSnapshot.inventory.rationCount = Math.max(
|
||||||
|
0,
|
||||||
|
run.adventurerSnapshot.inventory.rationCount - 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const healed = healAdventurer(run, 2);
|
||||||
|
const at = options.at ?? new Date().toISOString();
|
||||||
|
const logEntry = createLogEntry(
|
||||||
|
`recovery.ration-rest.${run.log.length + 1}`,
|
||||||
|
at,
|
||||||
|
`Shared a ration in town and recovered ${healed} HP.`,
|
||||||
|
[ration.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
run.log.push(logEntry);
|
||||||
|
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
logEntries: [logEntry],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConsumableCounts(run: RunState) {
|
||||||
|
const carried = run.adventurerSnapshot.inventory.carried;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ration: findCarriedEntry(carried, "item.ration")?.quantity ?? 0,
|
||||||
|
healingPotion: findCarriedEntry(carried, "potion.healing")?.quantity ?? 0,
|
||||||
|
lesserHealScroll: findCarriedEntry(carried, "scroll.lesser-heal")?.quantity ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+16
-2
@@ -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;
|
||||||
@@ -40,6 +41,18 @@ const DEFAULT_ROOM_DIMENSIONS: Record<RoomClass, { width: number; height: number
|
|||||||
|
|
||||||
const DEFAULT_DIRECTIONS = ["north", "east", "south", "west"] as const;
|
const DEFAULT_DIRECTIONS = ["north", "east", "south", "west"] as const;
|
||||||
|
|
||||||
|
function getDirectionSeed(roomId: string) {
|
||||||
|
return Array.from(roomId).reduce((total, char) => total + char.charCodeAt(0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectionOrder(roomId: string) {
|
||||||
|
const rotation = getDirectionSeed(roomId) % DEFAULT_DIRECTIONS.length;
|
||||||
|
return [
|
||||||
|
...DEFAULT_DIRECTIONS.slice(rotation),
|
||||||
|
...DEFAULT_DIRECTIONS.slice(0, rotation),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function inferExitType(exitHint?: string): ExitType {
|
function inferExitType(exitHint?: string): ExitType {
|
||||||
const normalized = exitHint?.toLowerCase() ?? "";
|
const normalized = exitHint?.toLowerCase() ?? "";
|
||||||
|
|
||||||
@@ -91,8 +104,9 @@ function createExits(
|
|||||||
): RoomExitState[] {
|
): RoomExitState[] {
|
||||||
const exitCount = inferExitCount(roomClass, exitHint);
|
const exitCount = inferExitCount(roomClass, exitHint);
|
||||||
const exitType = inferExitType(exitHint);
|
const exitType = inferExitType(exitHint);
|
||||||
|
const directionOrder = getDirectionOrder(roomId);
|
||||||
|
|
||||||
return DEFAULT_DIRECTIONS.slice(0, exitCount).map((direction, index) => ({
|
return directionOrder.slice(0, exitCount).map((direction, index) => ({
|
||||||
id: `${roomId}.exit.${index + 1}`,
|
id: `${roomId}.exit.${index + 1}`,
|
||||||
direction,
|
direction,
|
||||||
exitType,
|
exitType,
|
||||||
@@ -135,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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,774 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { sampleContentPack } from "@/data/sampleContentPack";
|
||||||
|
|
||||||
|
import { createStartingAdventurer } from "./character";
|
||||||
|
import { createRoomStateFromTemplate } from "./rooms";
|
||||||
|
import {
|
||||||
|
canCompleteCurrentLevel,
|
||||||
|
completeCurrentLevel,
|
||||||
|
createRunState,
|
||||||
|
enterCurrentRoom,
|
||||||
|
getAvailableMoves,
|
||||||
|
isCurrentRoomCombatReady,
|
||||||
|
resolveCurrentRoomObject,
|
||||||
|
resolveRunEnemyTurn,
|
||||||
|
resolveRunPlayerTurn,
|
||||||
|
resumeDungeon,
|
||||||
|
returnToTown,
|
||||||
|
searchCurrentRoom,
|
||||||
|
startCombatInCurrentRoom,
|
||||||
|
travelCurrentExit,
|
||||||
|
useRunMagicItem,
|
||||||
|
} from "./runState";
|
||||||
|
|
||||||
|
function createSequenceRoller(values: number[]) {
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const next = values[index];
|
||||||
|
index += 1;
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAdventurer() {
|
||||||
|
return createStartingAdventurer(sampleContentPack, {
|
||||||
|
name: "Aster",
|
||||||
|
weaponId: "weapon.short-sword",
|
||||||
|
armourId: "armour.leather-vest",
|
||||||
|
scrollId: "scroll.lesser-heal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("run state flow", () => {
|
||||||
|
it("creates a run anchored in the start room", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
at: "2026-03-15T14:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(run.currentLevel).toBe(1);
|
||||||
|
expect(run.currentRoomId).toBe("room.level1.start");
|
||||||
|
expect(run.phase).toBe("dungeon");
|
||||||
|
expect(run.dungeon.levels["1"]?.rooms["room.level1.start"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enters the current room and appends room logs", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
at: "2026-03-15T14:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = enterCurrentRoom({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run,
|
||||||
|
at: "2026-03-15T14:01:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.run.log).toHaveLength(1);
|
||||||
|
expect(result.run.log[0]?.text).toContain("Re-entered Entry Chamber");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals a fallback secret exit when room entry would otherwise stall progression", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
at: "2026-03-15T14:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
run.dungeon.levels["1"]!.rooms["room.level1.start"]!.exits = [];
|
||||||
|
|
||||||
|
const result = enterCurrentRoom({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run,
|
||||||
|
at: "2026-03-15T14:01:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.run.dungeon.levels["1"]!.secretDoorUsed).toBe(true);
|
||||||
|
expect(result.run.dungeon.levels["1"]!.rooms["room.level1.start"]!.exits).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ exitType: "secret" })]),
|
||||||
|
);
|
||||||
|
expect(result.run.log.at(-1)?.text).toContain("secret exit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts combat from the current room and stores the active combat state", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
at: "2026-03-15T14:00:00.000Z",
|
||||||
|
});
|
||||||
|
const levelState = run.dungeon.levels["1"]!;
|
||||||
|
const room = levelState.rooms["room.level1.start"]!;
|
||||||
|
|
||||||
|
room.encounter = {
|
||||||
|
id: `${room.id}.encounter`,
|
||||||
|
sourceTableCode: "L1G",
|
||||||
|
creatureIds: ["a", "b"],
|
||||||
|
creatureNames: ["Guard", "Warrior"],
|
||||||
|
resultLabel: "Guard and Warrior",
|
||||||
|
resolved: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = startCombatInCurrentRoom({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run,
|
||||||
|
at: "2026-03-15T14:02:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.run.activeCombat?.enemies).toHaveLength(2);
|
||||||
|
expect(result.run.log.at(-1)?.text).toContain("Combat begins");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves player and enemy turns through run state", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
at: "2026-03-15T14:00:00.000Z",
|
||||||
|
});
|
||||||
|
const levelState = run.dungeon.levels["1"]!;
|
||||||
|
const room = levelState.rooms["room.level1.start"]!;
|
||||||
|
|
||||||
|
room.encounter = {
|
||||||
|
id: `${room.id}.encounter`,
|
||||||
|
sourceTableCode: "L1G",
|
||||||
|
creatureIds: ["a", "b"],
|
||||||
|
creatureNames: ["Guard", "Warrior"],
|
||||||
|
resultLabel: "Guard and Warrior",
|
||||||
|
resolved: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const withCombat = startCombatInCurrentRoom({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run,
|
||||||
|
at: "2026-03-15T14:02:00.000Z",
|
||||||
|
}).run;
|
||||||
|
const targetEnemyId = withCombat.activeCombat!.enemies[0]!.id;
|
||||||
|
|
||||||
|
const afterPlayer = resolveRunPlayerTurn({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run: withCombat,
|
||||||
|
manoeuvreId: "manoeuvre.exact-strike",
|
||||||
|
targetEnemyId,
|
||||||
|
roller: createSequenceRoller([6, 6]),
|
||||||
|
at: "2026-03-15T14:03:00.000Z",
|
||||||
|
}).run;
|
||||||
|
|
||||||
|
expect(afterPlayer.activeCombat?.actingSide).toBe("enemy");
|
||||||
|
expect(afterPlayer.log.at(-1)?.text).toContain("deals");
|
||||||
|
|
||||||
|
const afterEnemy = resolveRunEnemyTurn({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run: afterPlayer,
|
||||||
|
roller: createSequenceRoller([6, 6]),
|
||||||
|
at: "2026-03-15T14:04:00.000Z",
|
||||||
|
}).run;
|
||||||
|
|
||||||
|
expect(afterEnemy.activeCombat?.actingSide).toBe("player");
|
||||||
|
expect(afterEnemy.adventurerSnapshot.hp.current).toBeLessThan(
|
||||||
|
createAdventurer().hp.current,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the room and ends active combat when the last enemy falls", () => {
|
||||||
|
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.encounter = {
|
||||||
|
id: `${room.id}.encounter`,
|
||||||
|
sourceTableCode: "L1G",
|
||||||
|
creatureIds: ["a"],
|
||||||
|
creatureNames: ["Guard"],
|
||||||
|
resultLabel: "Guard",
|
||||||
|
resolved: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const withCombat = startCombatInCurrentRoom({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run,
|
||||||
|
at: "2026-03-15T14:02:00.000Z",
|
||||||
|
}).run;
|
||||||
|
|
||||||
|
withCombat.activeCombat!.enemies[0]!.hpCurrent = 1;
|
||||||
|
|
||||||
|
const result = resolveRunPlayerTurn({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run: withCombat,
|
||||||
|
manoeuvreId: "manoeuvre.guard-break",
|
||||||
|
targetEnemyId: withCombat.activeCombat!.enemies[0]!.id,
|
||||||
|
roller: createSequenceRoller([6, 6, 5]),
|
||||||
|
at: "2026-03-15T14:03:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.run.activeCombat).toBeUndefined();
|
||||||
|
expect(result.run.dungeon.levels["1"]!.rooms["room.level1.start"]!.discovery.cleared).toBe(true);
|
||||||
|
expect(result.run.dungeon.levels["1"]!.rooms["room.level1.start"]!.encounter?.rewardPending).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(result.run.adventurerSnapshot.xp).toBe(2);
|
||||||
|
expect(result.run.xpGained).toBe(2);
|
||||||
|
expect(result.run.defeatedCreatureIds).toEqual(["creature.level1.guard"]);
|
||||||
|
expect(result.run.lastCombatOutcome?.result).toBe("victory");
|
||||||
|
expect(result.run.lastCombatOutcome?.xpAwarded).toBe(2);
|
||||||
|
expect(result.run.log.at(-1)?.text).toContain("Victory rewards");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies an immediate level-up when combat rewards cross the xp threshold", () => {
|
||||||
|
const adventurer = createAdventurer();
|
||||||
|
adventurer.xp = 7;
|
||||||
|
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer,
|
||||||
|
at: "2026-03-15T14:00:00.000Z",
|
||||||
|
});
|
||||||
|
const levelState = run.dungeon.levels["1"]!;
|
||||||
|
const room = levelState.rooms["room.level1.start"]!;
|
||||||
|
|
||||||
|
room.encounter = {
|
||||||
|
id: "encounter.start.guard",
|
||||||
|
sourceTableCode: "L1G",
|
||||||
|
creatureIds: ["creature.level1.guard"],
|
||||||
|
resultLabel: "Guard",
|
||||||
|
creatureNames: ["Guard"],
|
||||||
|
resolved: true,
|
||||||
|
};
|
||||||
|
room.discovery.entered = true;
|
||||||
|
|
||||||
|
const withCombat = startCombatInCurrentRoom({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run,
|
||||||
|
at: "2026-03-15T14:02:00.000Z",
|
||||||
|
}).run;
|
||||||
|
withCombat.activeCombat!.enemies[0]!.hpCurrent = 1;
|
||||||
|
|
||||||
|
const result = resolveRunPlayerTurn({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run: withCombat,
|
||||||
|
manoeuvreId: "manoeuvre.guard-break",
|
||||||
|
targetEnemyId: withCombat.activeCombat!.enemies[0]!.id,
|
||||||
|
roller: createSequenceRoller([6, 6, 5]),
|
||||||
|
at: "2026-03-15T14:03:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.run.adventurerSnapshot.level).toBe(2);
|
||||||
|
expect(result.run.adventurerSnapshot.hp.max).toBe(12);
|
||||||
|
expect(result.run.adventurerSnapshot.xp).toBe(9);
|
||||||
|
expect(result.run.adventurerSnapshot.manoeuvreIds).toContain("manoeuvre.sweeping-cut");
|
||||||
|
expect(result.run.lastLevelUp?.newLevel).toBe(2);
|
||||||
|
expect(result.run.log.at(-1)?.text).toContain("Reached level 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a defeat outcome when the enemy drops the adventurer", () => {
|
||||||
|
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.encounter = {
|
||||||
|
id: `${room.id}.encounter`,
|
||||||
|
sourceTableCode: "L1G",
|
||||||
|
creatureIds: ["a"],
|
||||||
|
creatureNames: ["Warrior"],
|
||||||
|
resultLabel: "Warrior",
|
||||||
|
resolved: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const withCombat = startCombatInCurrentRoom({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run,
|
||||||
|
at: "2026-03-15T14:02:00.000Z",
|
||||||
|
}).run;
|
||||||
|
|
||||||
|
withCombat.activeCombat!.actingSide = "enemy";
|
||||||
|
withCombat.activeCombat!.player.hpCurrent = 1;
|
||||||
|
withCombat.adventurerSnapshot.hp.current = 1;
|
||||||
|
|
||||||
|
const result = resolveRunEnemyTurn({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run: withCombat,
|
||||||
|
roller: createSequenceRoller([6, 6]),
|
||||||
|
at: "2026-03-15T14:03:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.run.status).toBe("failed");
|
||||||
|
expect(result.run.lastCombatOutcome?.result).toBe("defeat");
|
||||||
|
expect(result.run.lastCombatOutcome?.summary).toContain("defeated");
|
||||||
|
expect(result.run.log.at(-1)?.text).toContain("defeated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists available traversable exits for the current room", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getAvailableMoves(run)).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
direction: "north",
|
||||||
|
generated: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("travels through an unresolved exit, generates a room, and enters it", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
at: "2026-03-15T14:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = travelCurrentExit({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run,
|
||||||
|
exitDirection: "north",
|
||||||
|
roller: createSequenceRoller([1, 1]),
|
||||||
|
at: "2026-03-15T14:05:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.run.currentRoomId).toBe("room.level1.room.002");
|
||||||
|
expect(result.run.dungeon.levels["1"]!.discoveredRoomOrder).toEqual([
|
||||||
|
"room.level1.start",
|
||||||
|
"room.level1.room.002",
|
||||||
|
]);
|
||||||
|
expect(result.run.dungeon.levels["1"]!.rooms["room.level1.room.002"]!.discovery.entered).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result.run.log[0]?.text).toContain("Travelled north");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags combat-ready rooms once entry resolves a hostile encounter", () => {
|
||||||
|
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.encounter = {
|
||||||
|
id: `${room.id}.encounter`,
|
||||||
|
sourceTableCode: "L1G",
|
||||||
|
creatureIds: ["a", "b"],
|
||||||
|
creatureNames: ["Guard", "Warrior"],
|
||||||
|
resultLabel: "Guard and Warrior",
|
||||||
|
resolved: 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", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
at: "2026-03-15T14:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const inTown = returnToTown(run, "2026-03-15T15:00:00.000Z").run;
|
||||||
|
const resumed = resumeDungeon(inTown, "2026-03-15T15:10:00.000Z").run;
|
||||||
|
|
||||||
|
expect(inTown.phase).toBe("town");
|
||||||
|
expect(inTown.lastTownAt).toBe("2026-03-15T15:00:00.000Z");
|
||||||
|
expect(getAvailableMoves(inTown)).toEqual([]);
|
||||||
|
expect(resumed.phase).toBe("dungeon");
|
||||||
|
expect(resumed.log.at(-1)?.text).toContain("resumed the dungeon delve");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places stairs and completes the current level when the map is exhausted", () => {
|
||||||
|
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.discovery.cleared = true;
|
||||||
|
room.exits = [];
|
||||||
|
|
||||||
|
expect(canCompleteCurrentLevel(run)).toBe(true);
|
||||||
|
|
||||||
|
const result = completeCurrentLevel(run, "2026-03-15T15:30:00.000Z");
|
||||||
|
|
||||||
|
expect(result.run.phase).toBe("town");
|
||||||
|
expect(result.run.dungeon.levels["1"]!.stairsDownRoomId).toBe("room.level1.start");
|
||||||
|
expect(result.run.dungeon.globalFlags).toContain("level:1:completed");
|
||||||
|
expect(result.run.log.at(-1)?.text).toContain("Returned to town");
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { sampleContentPack } from "@/data/sampleContentPack";
|
||||||
|
|
||||||
|
import { createStartingAdventurer } from "./character";
|
||||||
|
import { createRunState } from "./runState";
|
||||||
|
import { queueTreasureForSale, sellPendingTreasure, sendTreasureToStash } from "./town";
|
||||||
|
|
||||||
|
function createAdventurer() {
|
||||||
|
return createStartingAdventurer(sampleContentPack, {
|
||||||
|
name: "Aster",
|
||||||
|
weaponId: "weapon.short-sword",
|
||||||
|
armourId: "armour.leather-vest",
|
||||||
|
scrollId: "scroll.lesser-heal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("town flow", () => {
|
||||||
|
it("moves treasure from carried inventory into the stash", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
run.adventurerSnapshot.inventory.carried.push({
|
||||||
|
definitionId: "item.silver-clasp",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = sendTreasureToStash(
|
||||||
|
sampleContentPack,
|
||||||
|
run,
|
||||||
|
"item.silver-clasp",
|
||||||
|
"2026-03-15T15:00:00.000Z",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.run.townState.stash).toEqual([
|
||||||
|
{ definitionId: "item.silver-clasp", quantity: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.run.adventurerSnapshot.inventory.carried).not.toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ definitionId: "item.silver-clasp" })]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues treasure and sells it for item value", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
run.adventurerSnapshot.inventory.carried.push({
|
||||||
|
definitionId: "item.keeper-keyring",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queued = queueTreasureForSale(
|
||||||
|
sampleContentPack,
|
||||||
|
run,
|
||||||
|
"item.keeper-keyring",
|
||||||
|
"2026-03-15T15:05:00.000Z",
|
||||||
|
).run;
|
||||||
|
const sold = sellPendingTreasure(
|
||||||
|
sampleContentPack,
|
||||||
|
queued,
|
||||||
|
"2026-03-15T15:06:00.000Z",
|
||||||
|
).run;
|
||||||
|
|
||||||
|
expect(sold.townState.pendingSales).toEqual([]);
|
||||||
|
expect(sold.adventurerSnapshot.inventory.currency.gold).toBe(5);
|
||||||
|
expect(sold.log.at(-1)?.text).toContain("for 5 gold");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { findItemById } from "@/data/contentHelpers";
|
||||||
|
import type { ContentPack } from "@/types/content";
|
||||||
|
import type { InventoryEntry, RunState, TownState } from "@/types/state";
|
||||||
|
import type { LogEntry } from "@/types/rules";
|
||||||
|
|
||||||
|
export type TownActionResult = {
|
||||||
|
run: RunState;
|
||||||
|
logEntries: LogEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function cloneTownState(townState: TownState): TownState {
|
||||||
|
return {
|
||||||
|
...townState,
|
||||||
|
knownServices: [...townState.knownServices],
|
||||||
|
stash: townState.stash.map((entry) => ({ ...entry })),
|
||||||
|
pendingSales: townState.pendingSales.map((entry) => ({ ...entry })),
|
||||||
|
serviceFlags: [...townState.serviceFlags],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneRun(run: RunState): RunState {
|
||||||
|
return {
|
||||||
|
...run,
|
||||||
|
adventurerSnapshot: {
|
||||||
|
...run.adventurerSnapshot,
|
||||||
|
hp: { ...run.adventurerSnapshot.hp },
|
||||||
|
stats: { ...run.adventurerSnapshot.stats },
|
||||||
|
favour: { ...run.adventurerSnapshot.favour },
|
||||||
|
statuses: run.adventurerSnapshot.statuses.map((status) => ({ ...status })),
|
||||||
|
inventory: {
|
||||||
|
...run.adventurerSnapshot.inventory,
|
||||||
|
carried: run.adventurerSnapshot.inventory.carried.map((entry) => ({ ...entry })),
|
||||||
|
equipped: run.adventurerSnapshot.inventory.equipped.map((entry) => ({ ...entry })),
|
||||||
|
stored: run.adventurerSnapshot.inventory.stored.map((entry) => ({ ...entry })),
|
||||||
|
currency: { ...run.adventurerSnapshot.inventory.currency },
|
||||||
|
lightSources: run.adventurerSnapshot.inventory.lightSources.map((entry) => ({ ...entry })),
|
||||||
|
},
|
||||||
|
progressionFlags: [...run.adventurerSnapshot.progressionFlags],
|
||||||
|
manoeuvreIds: [...run.adventurerSnapshot.manoeuvreIds],
|
||||||
|
},
|
||||||
|
townState: cloneTownState(run.townState),
|
||||||
|
defeatedCreatureIds: [...run.defeatedCreatureIds],
|
||||||
|
lootedItems: run.lootedItems.map((entry) => ({ ...entry })),
|
||||||
|
log: run.log.map((entry) => ({ ...entry, relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined })),
|
||||||
|
pendingEffects: run.pendingEffects.map((effect) => ({ ...effect })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeEntry(entries: InventoryEntry[], entry: InventoryEntry) {
|
||||||
|
const existing = entries.find((candidate) => candidate.definitionId === entry.definitionId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += entry.quantity;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({ ...entry });
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEntry(entries: InventoryEntry[], definitionId: string) {
|
||||||
|
const index = entries.findIndex((entry) => entry.definitionId === definitionId);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error(`No inventory entry found for ${definitionId}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [removed] = entries.splice(index, 1);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTownLog(id: string, at: string, text: string, relatedIds: string[]): LogEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
at,
|
||||||
|
type: "town",
|
||||||
|
text,
|
||||||
|
relatedIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialTownState(): TownState {
|
||||||
|
return {
|
||||||
|
visits: 0,
|
||||||
|
knownServices: ["service.market"],
|
||||||
|
stash: [],
|
||||||
|
pendingSales: [],
|
||||||
|
serviceFlags: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendTreasureToStash(
|
||||||
|
content: ContentPack,
|
||||||
|
run: RunState,
|
||||||
|
definitionId: string,
|
||||||
|
at = new Date().toISOString(),
|
||||||
|
): TownActionResult {
|
||||||
|
const nextRun = cloneRun(run);
|
||||||
|
const removed = extractEntry(nextRun.adventurerSnapshot.inventory.carried, definitionId);
|
||||||
|
mergeEntry(nextRun.townState.stash, removed);
|
||||||
|
nextRun.townState.visits += 1;
|
||||||
|
const item = findItemById(content, definitionId);
|
||||||
|
const logEntry = createTownLog(
|
||||||
|
`town.stash.${definitionId}.${nextRun.log.length + 1}`,
|
||||||
|
at,
|
||||||
|
`Moved ${item.name} into the town stash.`,
|
||||||
|
[definitionId],
|
||||||
|
);
|
||||||
|
nextRun.log.push(logEntry);
|
||||||
|
|
||||||
|
return { run: nextRun, logEntries: [logEntry] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueTreasureForSale(
|
||||||
|
content: ContentPack,
|
||||||
|
run: RunState,
|
||||||
|
definitionId: string,
|
||||||
|
at = new Date().toISOString(),
|
||||||
|
): TownActionResult {
|
||||||
|
const nextRun = cloneRun(run);
|
||||||
|
const removed = extractEntry(nextRun.adventurerSnapshot.inventory.carried, definitionId);
|
||||||
|
mergeEntry(nextRun.townState.pendingSales, removed);
|
||||||
|
nextRun.townState.visits += 1;
|
||||||
|
const item = findItemById(content, definitionId);
|
||||||
|
const logEntry = createTownLog(
|
||||||
|
`town.queue.${definitionId}.${nextRun.log.length + 1}`,
|
||||||
|
at,
|
||||||
|
`Queued ${item.name} for sale at the market.`,
|
||||||
|
[definitionId],
|
||||||
|
);
|
||||||
|
nextRun.log.push(logEntry);
|
||||||
|
|
||||||
|
return { run: nextRun, logEntries: [logEntry] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sellPendingTreasure(
|
||||||
|
content: ContentPack,
|
||||||
|
run: RunState,
|
||||||
|
at = new Date().toISOString(),
|
||||||
|
): TownActionResult {
|
||||||
|
const nextRun = cloneRun(run);
|
||||||
|
const soldEntries = nextRun.townState.pendingSales;
|
||||||
|
const goldEarned = soldEntries.reduce((total, entry) => {
|
||||||
|
const item = findItemById(content, entry.definitionId);
|
||||||
|
return total + (item.valueGp ?? 0) * entry.quantity;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
nextRun.adventurerSnapshot.inventory.currency.gold += goldEarned;
|
||||||
|
nextRun.townState.pendingSales = [];
|
||||||
|
nextRun.townState.visits += 1;
|
||||||
|
|
||||||
|
const soldText =
|
||||||
|
soldEntries.length === 0
|
||||||
|
? "No treasure was queued for sale."
|
||||||
|
: `Sold ${soldEntries.reduce((total, entry) => total + entry.quantity, 0)} treasure item${soldEntries.reduce((total, entry) => total + entry.quantity, 0) === 1 ? "" : "s"} for ${goldEarned} gold.`;
|
||||||
|
|
||||||
|
const logEntry = createTownLog(
|
||||||
|
`town.sell.${nextRun.log.length + 1}`,
|
||||||
|
at,
|
||||||
|
soldText,
|
||||||
|
soldEntries.map((entry) => entry.definitionId),
|
||||||
|
);
|
||||||
|
nextRun.log.push(logEntry);
|
||||||
|
|
||||||
|
return { run: nextRun, logEntries: [logEntry] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { sampleContentPack } from "@/data/sampleContentPack";
|
||||||
|
|
||||||
|
import { createStartingAdventurer } from "./character";
|
||||||
|
import { createRunState, returnToTown } from "./runState";
|
||||||
|
import {
|
||||||
|
grantDebugTreasure,
|
||||||
|
queueTreasureForSale,
|
||||||
|
sellPendingTreasure,
|
||||||
|
stashCarriedTreasure,
|
||||||
|
withdrawStashedTreasure,
|
||||||
|
} from "./townInventory";
|
||||||
|
|
||||||
|
function createAdventurer() {
|
||||||
|
return createStartingAdventurer(sampleContentPack, {
|
||||||
|
name: "Aster",
|
||||||
|
weaponId: "weapon.short-sword",
|
||||||
|
armourId: "armour.leather-vest",
|
||||||
|
scrollId: "scroll.lesser-heal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTownRun() {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return returnToTown(run, "2026-03-18T21:00:00.000Z").run;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("town inventory loop", () => {
|
||||||
|
it("stores carried treasure in the town stash", () => {
|
||||||
|
const inTown = createTownRun();
|
||||||
|
inTown.adventurerSnapshot.inventory.carried.push({
|
||||||
|
definitionId: "item.silver-chalice",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = stashCarriedTreasure({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run: inTown,
|
||||||
|
definitionId: "item.silver-chalice",
|
||||||
|
at: "2026-03-18T21:05:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.run.adventurerSnapshot.inventory.carried).not.toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ definitionId: "item.silver-chalice" })]),
|
||||||
|
);
|
||||||
|
expect(result.run.townState.stash).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
definitionId: "item.silver-chalice",
|
||||||
|
quantity: 1,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withdraws stashed treasure back to the pack", () => {
|
||||||
|
const inTown = createTownRun();
|
||||||
|
inTown.townState.stash.push({
|
||||||
|
definitionId: "item.garnet-ring",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = withdrawStashedTreasure({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run: inTown,
|
||||||
|
definitionId: "item.garnet-ring",
|
||||||
|
at: "2026-03-18T21:06:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.run.townState.stash).toHaveLength(0);
|
||||||
|
expect(result.run.adventurerSnapshot.inventory.carried).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
definitionId: "item.garnet-ring",
|
||||||
|
quantity: 1,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues treasure for sale and converts it into gold", () => {
|
||||||
|
const inTown = createTownRun();
|
||||||
|
const withTreasure = grantDebugTreasure({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run: inTown,
|
||||||
|
definitionId: "item.garnet-ring",
|
||||||
|
quantity: 2,
|
||||||
|
at: "2026-03-18T21:07:00.000Z",
|
||||||
|
}).run;
|
||||||
|
|
||||||
|
const queued = queueTreasureForSale({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run: withTreasure,
|
||||||
|
definitionId: "item.garnet-ring",
|
||||||
|
quantity: 2,
|
||||||
|
source: "carried",
|
||||||
|
at: "2026-03-18T21:08:00.000Z",
|
||||||
|
}).run;
|
||||||
|
|
||||||
|
const sold = sellPendingTreasure({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run: queued,
|
||||||
|
at: "2026-03-18T21:09:00.000Z",
|
||||||
|
}).run;
|
||||||
|
|
||||||
|
expect(queued.townState.pendingSales).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
definitionId: "item.garnet-ring",
|
||||||
|
quantity: 2,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(sold.townState.pendingSales).toHaveLength(0);
|
||||||
|
expect(sold.adventurerSnapshot.inventory.currency.gold).toBe(
|
||||||
|
withTreasure.adventurerSnapshot.inventory.currency.gold + 24,
|
||||||
|
);
|
||||||
|
expect(sold.log.at(-1)?.text).toContain("24 gold");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { findItemById } from "@/data/contentHelpers";
|
||||||
|
import type { ContentPack } from "@/types/content";
|
||||||
|
import type { InventoryEntry, RunState, TownState } from "@/types/state";
|
||||||
|
import type { LogEntry } from "@/types/rules";
|
||||||
|
|
||||||
|
export type TownInventoryResult = {
|
||||||
|
run: RunState;
|
||||||
|
logEntries: LogEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TownInventoryActionOptions = {
|
||||||
|
content: ContentPack;
|
||||||
|
run: RunState;
|
||||||
|
definitionId: string;
|
||||||
|
quantity?: number;
|
||||||
|
at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SellPendingTreasureOptions = {
|
||||||
|
content: ContentPack;
|
||||||
|
run: RunState;
|
||||||
|
at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function cloneTownState(townState: TownState): TownState {
|
||||||
|
return {
|
||||||
|
...townState,
|
||||||
|
knownServices: [...townState.knownServices],
|
||||||
|
stash: townState.stash.map((entry) => ({ ...entry })),
|
||||||
|
pendingSales: townState.pendingSales.map((entry) => ({ ...entry })),
|
||||||
|
serviceFlags: [...townState.serviceFlags],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneRun(run: RunState): RunState {
|
||||||
|
return {
|
||||||
|
...run,
|
||||||
|
adventurerSnapshot: {
|
||||||
|
...run.adventurerSnapshot,
|
||||||
|
hp: { ...run.adventurerSnapshot.hp },
|
||||||
|
stats: { ...run.adventurerSnapshot.stats },
|
||||||
|
favour: { ...run.adventurerSnapshot.favour },
|
||||||
|
statuses: run.adventurerSnapshot.statuses.map((status) => ({ ...status })),
|
||||||
|
inventory: {
|
||||||
|
...run.adventurerSnapshot.inventory,
|
||||||
|
carried: run.adventurerSnapshot.inventory.carried.map((entry) => ({ ...entry })),
|
||||||
|
equipped: run.adventurerSnapshot.inventory.equipped.map((entry) => ({ ...entry })),
|
||||||
|
stored: run.adventurerSnapshot.inventory.stored.map((entry) => ({ ...entry })),
|
||||||
|
currency: { ...run.adventurerSnapshot.inventory.currency },
|
||||||
|
lightSources: run.adventurerSnapshot.inventory.lightSources.map((entry) => ({ ...entry })),
|
||||||
|
},
|
||||||
|
progressionFlags: [...run.adventurerSnapshot.progressionFlags],
|
||||||
|
manoeuvreIds: [...run.adventurerSnapshot.manoeuvreIds],
|
||||||
|
},
|
||||||
|
townState: cloneTownState(run.townState),
|
||||||
|
defeatedCreatureIds: [...run.defeatedCreatureIds],
|
||||||
|
log: run.log.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined,
|
||||||
|
})),
|
||||||
|
pendingEffects: run.pendingEffects.map((effect) => ({ ...effect })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireTownPhase(run: RunState) {
|
||||||
|
if (run.phase !== "town") {
|
||||||
|
throw new Error("Town inventory actions are only available while the run is in town.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEntry(entries: InventoryEntry[], definitionId: string, quantity: number) {
|
||||||
|
const existing = entries.find((entry) => entry.definitionId === definitionId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += quantity;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
definitionId,
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEntry(entries: InventoryEntry[], definitionId: string, quantity: number) {
|
||||||
|
const existing = entries.find((entry) => entry.definitionId === definitionId);
|
||||||
|
|
||||||
|
if (!existing || existing.quantity < quantity) {
|
||||||
|
throw new Error(`Not enough ${definitionId} available for this action.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.quantity -= quantity;
|
||||||
|
|
||||||
|
if (existing.quantity === 0) {
|
||||||
|
const index = entries.indexOf(existing);
|
||||||
|
entries.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQuantity(quantity?: number) {
|
||||||
|
if (!quantity) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(quantity) || quantity <= 0) {
|
||||||
|
throw new Error(`Invalid quantity requested: ${quantity}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireTreasureItem(content: ContentPack, definitionId: string) {
|
||||||
|
const item = findItemById(content, definitionId);
|
||||||
|
|
||||||
|
if (item.itemType !== "treasure") {
|
||||||
|
throw new Error(`${item.name} is not eligible for the town treasure loop.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTownLog(
|
||||||
|
id: string,
|
||||||
|
at: string,
|
||||||
|
text: string,
|
||||||
|
relatedIds: string[],
|
||||||
|
): LogEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
at,
|
||||||
|
type: "town",
|
||||||
|
text,
|
||||||
|
relatedIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stashCarriedTreasure(
|
||||||
|
options: TownInventoryActionOptions,
|
||||||
|
): TownInventoryResult {
|
||||||
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
requireTownPhase(run);
|
||||||
|
|
||||||
|
const item = requireTreasureItem(options.content, options.definitionId);
|
||||||
|
const quantity = resolveQuantity(options.quantity);
|
||||||
|
const at = options.at ?? new Date().toISOString();
|
||||||
|
|
||||||
|
removeEntry(run.adventurerSnapshot.inventory.carried, item.id, quantity);
|
||||||
|
addEntry(run.townState.stash, item.id, quantity);
|
||||||
|
|
||||||
|
const logEntry = createTownLog(
|
||||||
|
`town.stash.${item.id}.${run.log.length + 1}`,
|
||||||
|
at,
|
||||||
|
`Stored ${quantity} ${item.name}${quantity === 1 ? "" : "s"} in the town stash.`,
|
||||||
|
[item.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
run.log.push(logEntry);
|
||||||
|
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
logEntries: [logEntry],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withdrawStashedTreasure(
|
||||||
|
options: TownInventoryActionOptions,
|
||||||
|
): TownInventoryResult {
|
||||||
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
requireTownPhase(run);
|
||||||
|
|
||||||
|
const item = requireTreasureItem(options.content, options.definitionId);
|
||||||
|
const quantity = resolveQuantity(options.quantity);
|
||||||
|
const at = options.at ?? new Date().toISOString();
|
||||||
|
|
||||||
|
removeEntry(run.townState.stash, item.id, quantity);
|
||||||
|
addEntry(run.adventurerSnapshot.inventory.carried, item.id, quantity);
|
||||||
|
|
||||||
|
const logEntry = createTownLog(
|
||||||
|
`town.withdraw.${item.id}.${run.log.length + 1}`,
|
||||||
|
at,
|
||||||
|
`Withdrew ${quantity} ${item.name}${quantity === 1 ? "" : "s"} from the town stash.`,
|
||||||
|
[item.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
run.log.push(logEntry);
|
||||||
|
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
logEntries: [logEntry],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueTreasureForSale(
|
||||||
|
options: TownInventoryActionOptions & {
|
||||||
|
source: "carried" | "stash";
|
||||||
|
},
|
||||||
|
): TownInventoryResult {
|
||||||
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
requireTownPhase(run);
|
||||||
|
|
||||||
|
const item = requireTreasureItem(options.content, options.definitionId);
|
||||||
|
const quantity = resolveQuantity(options.quantity);
|
||||||
|
const at = options.at ?? new Date().toISOString();
|
||||||
|
const sourceEntries =
|
||||||
|
options.source === "carried"
|
||||||
|
? run.adventurerSnapshot.inventory.carried
|
||||||
|
: run.townState.stash;
|
||||||
|
|
||||||
|
removeEntry(sourceEntries, item.id, quantity);
|
||||||
|
addEntry(run.townState.pendingSales, item.id, quantity);
|
||||||
|
|
||||||
|
const sourceLabel = options.source === "carried" ? "pack" : "stash";
|
||||||
|
const logEntry = createTownLog(
|
||||||
|
`town.sale-queue.${item.id}.${run.log.length + 1}`,
|
||||||
|
at,
|
||||||
|
`Queued ${quantity} ${item.name}${quantity === 1 ? "" : "s"} from the ${sourceLabel} for sale.`,
|
||||||
|
[item.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
run.log.push(logEntry);
|
||||||
|
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
logEntries: [logEntry],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sellPendingTreasure(
|
||||||
|
options: SellPendingTreasureOptions,
|
||||||
|
): TownInventoryResult {
|
||||||
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
requireTownPhase(run);
|
||||||
|
|
||||||
|
if (run.townState.pendingSales.length === 0) {
|
||||||
|
throw new Error("There is no treasure queued for sale.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = options.at ?? new Date().toISOString();
|
||||||
|
const totalGold = run.townState.pendingSales.reduce((total, entry) => {
|
||||||
|
const item = requireTreasureItem(options.content, entry.definitionId);
|
||||||
|
return total + (item.valueGp ?? 0) * entry.quantity;
|
||||||
|
}, 0);
|
||||||
|
const soldIds = run.townState.pendingSales.map((entry) => entry.definitionId);
|
||||||
|
|
||||||
|
run.adventurerSnapshot.inventory.currency.gold += totalGold;
|
||||||
|
run.townState.pendingSales = [];
|
||||||
|
|
||||||
|
const logEntry = createTownLog(
|
||||||
|
`town.sell-pending.${run.log.length + 1}`,
|
||||||
|
at,
|
||||||
|
`Sold queued treasure for ${totalGold} gold.`,
|
||||||
|
soldIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
run.log.push(logEntry);
|
||||||
|
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
logEntries: [logEntry],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function grantDebugTreasure(
|
||||||
|
options: TownInventoryActionOptions,
|
||||||
|
): TownInventoryResult {
|
||||||
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
requireTownPhase(run);
|
||||||
|
|
||||||
|
const item = requireTreasureItem(options.content, options.definitionId);
|
||||||
|
const quantity = resolveQuantity(options.quantity);
|
||||||
|
const at = options.at ?? new Date().toISOString();
|
||||||
|
|
||||||
|
addEntry(run.adventurerSnapshot.inventory.carried, item.id, quantity);
|
||||||
|
|
||||||
|
const logEntry = createTownLog(
|
||||||
|
`town.debug-grant.${item.id}.${run.log.length + 1}`,
|
||||||
|
at,
|
||||||
|
`Debug grant: added ${quantity} ${item.name}${quantity === 1 ? "" : "s"} to the pack.`,
|
||||||
|
[item.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
run.log.push(logEntry);
|
||||||
|
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
logEntries: [logEntry],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { sampleContentPack } from "@/data/sampleContentPack";
|
||||||
|
|
||||||
|
import { createStartingAdventurer } from "./character";
|
||||||
|
import { createRunState, returnToTown } from "./runState";
|
||||||
|
import { useTownService } from "./townServices";
|
||||||
|
|
||||||
|
function createAdventurer() {
|
||||||
|
return createStartingAdventurer(sampleContentPack, {
|
||||||
|
name: "Aster",
|
||||||
|
weaponId: "weapon.short-sword",
|
||||||
|
armourId: "armour.leather-vest",
|
||||||
|
scrollId: "scroll.lesser-heal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("town services", () => {
|
||||||
|
it("heals the adventurer to full at the healer", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
run.adventurerSnapshot.hp.current = 3;
|
||||||
|
run.adventurerSnapshot.inventory.currency.gold = 3;
|
||||||
|
|
||||||
|
const inTown = returnToTown(run, "2026-03-18T21:00:00.000Z").run;
|
||||||
|
const result = useTownService({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run: inTown,
|
||||||
|
serviceId: "service.healer",
|
||||||
|
at: "2026-03-18T21:05:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.run.adventurerSnapshot.hp.current).toBe(
|
||||||
|
result.run.adventurerSnapshot.hp.max,
|
||||||
|
);
|
||||||
|
expect(result.run.adventurerSnapshot.inventory.currency.gold).toBe(1);
|
||||||
|
expect(result.run.log.at(-1)?.text).toContain("restored the party to full health");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buys a ration at the market", () => {
|
||||||
|
const run = createRunState({
|
||||||
|
content: sampleContentPack,
|
||||||
|
campaignId: "campaign.1",
|
||||||
|
adventurer: createAdventurer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
run.adventurerSnapshot.inventory.currency.gold = 2;
|
||||||
|
|
||||||
|
const inTown = returnToTown(run).run;
|
||||||
|
const result = useTownService({
|
||||||
|
content: sampleContentPack,
|
||||||
|
run: inTown,
|
||||||
|
serviceId: "service.market",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.run.adventurerSnapshot.inventory.currency.gold).toBe(1);
|
||||||
|
expect(result.run.adventurerSnapshot.inventory.rationCount).toBe(4);
|
||||||
|
expect(result.run.adventurerSnapshot.inventory.carried).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
definitionId: "item.ration",
|
||||||
|
quantity: 4,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { ContentPack, TownServiceDefinition } from "@/types/content";
|
||||||
|
import type { InventoryEntry, RunState, TownState } from "@/types/state";
|
||||||
|
import type { LogEntry } from "@/types/rules";
|
||||||
|
|
||||||
|
export type UseTownServiceOptions = {
|
||||||
|
content: ContentPack;
|
||||||
|
run: RunState;
|
||||||
|
serviceId: string;
|
||||||
|
at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TownServiceResult = {
|
||||||
|
run: RunState;
|
||||||
|
logEntries: LogEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function cloneTownState(townState: TownState): TownState {
|
||||||
|
return {
|
||||||
|
...townState,
|
||||||
|
knownServices: [...townState.knownServices],
|
||||||
|
stash: townState.stash.map((entry) => ({ ...entry })),
|
||||||
|
pendingSales: townState.pendingSales.map((entry) => ({ ...entry })),
|
||||||
|
serviceFlags: [...townState.serviceFlags],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneRun(run: RunState): RunState {
|
||||||
|
return {
|
||||||
|
...run,
|
||||||
|
adventurerSnapshot: {
|
||||||
|
...run.adventurerSnapshot,
|
||||||
|
hp: { ...run.adventurerSnapshot.hp },
|
||||||
|
stats: { ...run.adventurerSnapshot.stats },
|
||||||
|
favour: { ...run.adventurerSnapshot.favour },
|
||||||
|
statuses: run.adventurerSnapshot.statuses.map((status) => ({ ...status })),
|
||||||
|
inventory: {
|
||||||
|
...run.adventurerSnapshot.inventory,
|
||||||
|
carried: run.adventurerSnapshot.inventory.carried.map((entry) => ({ ...entry })),
|
||||||
|
equipped: run.adventurerSnapshot.inventory.equipped.map((entry) => ({ ...entry })),
|
||||||
|
stored: run.adventurerSnapshot.inventory.stored.map((entry) => ({ ...entry })),
|
||||||
|
currency: { ...run.adventurerSnapshot.inventory.currency },
|
||||||
|
lightSources: run.adventurerSnapshot.inventory.lightSources.map((entry) => ({ ...entry })),
|
||||||
|
},
|
||||||
|
progressionFlags: [...run.adventurerSnapshot.progressionFlags],
|
||||||
|
manoeuvreIds: [...run.adventurerSnapshot.manoeuvreIds],
|
||||||
|
},
|
||||||
|
townState: cloneTownState(run.townState),
|
||||||
|
defeatedCreatureIds: [...run.defeatedCreatureIds],
|
||||||
|
log: run.log.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined,
|
||||||
|
})),
|
||||||
|
pendingEffects: run.pendingEffects.map((effect) => ({ ...effect })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTownService(content: ContentPack, serviceId: string): TownServiceDefinition {
|
||||||
|
const service = content.townServices.find((candidate) => candidate.id === serviceId);
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
throw new Error(`Unknown town service id: ${serviceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCost(service: TownServiceDefinition) {
|
||||||
|
const rawRule = service.costRules?.[0];
|
||||||
|
const amount = rawRule ? Number(rawRule.split(":")[1] ?? 0) : 0;
|
||||||
|
|
||||||
|
return Number.isFinite(amount) ? amount : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCarriedEntry(entries: InventoryEntry[], definitionId: string, quantity = 1) {
|
||||||
|
const existing = entries.find((entry) => entry.definitionId === definitionId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += quantity;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
definitionId,
|
||||||
|
quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTownLog(
|
||||||
|
id: string,
|
||||||
|
at: string,
|
||||||
|
text: string,
|
||||||
|
relatedIds: string[],
|
||||||
|
): LogEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
at,
|
||||||
|
type: "town",
|
||||||
|
text,
|
||||||
|
relatedIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialTownState(): TownState {
|
||||||
|
return {
|
||||||
|
visits: 0,
|
||||||
|
knownServices: ["service.market", "service.healer", "service.tavern"],
|
||||||
|
stash: [],
|
||||||
|
pendingSales: [],
|
||||||
|
serviceFlags: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTownService(options: UseTownServiceOptions): TownServiceResult {
|
||||||
|
const run = cloneRun(options.run);
|
||||||
|
|
||||||
|
if (run.phase !== "town") {
|
||||||
|
throw new Error("Town services are only available while the run is in town.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!run.townState.knownServices.includes(options.serviceId)) {
|
||||||
|
throw new Error(`Service ${options.serviceId} is not available in town.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = findTownService(options.content, options.serviceId);
|
||||||
|
const at = options.at ?? new Date().toISOString();
|
||||||
|
const cost = parseCost(service);
|
||||||
|
|
||||||
|
if (run.adventurerSnapshot.inventory.currency.gold < cost) {
|
||||||
|
throw new Error(`Not enough gold to use ${service.name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
run.adventurerSnapshot.inventory.currency.gold -= cost;
|
||||||
|
run.townState.serviceFlags.push(`${service.id}.used`);
|
||||||
|
|
||||||
|
let text = `${service.name} was used.`;
|
||||||
|
|
||||||
|
switch (service.serviceType) {
|
||||||
|
case "healer":
|
||||||
|
run.adventurerSnapshot.hp.current = run.adventurerSnapshot.hp.max;
|
||||||
|
text = `${service.name} restored the party to full health for ${cost} gold.`;
|
||||||
|
break;
|
||||||
|
case "market":
|
||||||
|
addCarriedEntry(run.adventurerSnapshot.inventory.carried, "item.ration", 1);
|
||||||
|
run.adventurerSnapshot.inventory.rationCount += 1;
|
||||||
|
text = `${service.name} supplied 1 ration for ${cost} gold.`;
|
||||||
|
break;
|
||||||
|
case "tavern":
|
||||||
|
run.adventurerSnapshot.hp.current = Math.min(
|
||||||
|
run.adventurerSnapshot.hp.max,
|
||||||
|
run.adventurerSnapshot.hp.current + 2,
|
||||||
|
);
|
||||||
|
text = `${service.name} provided a warm meal and 2 HP of recovery for ${cost} gold.`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
text = `${service.name} was visited for ${cost} gold.`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logEntry = createTownLog(
|
||||||
|
`town.service.${service.id}.${run.log.length + 1}`,
|
||||||
|
at,
|
||||||
|
text,
|
||||||
|
[service.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
run.log.push(logEntry);
|
||||||
|
|
||||||
|
return {
|
||||||
|
run,
|
||||||
|
logEntries: [logEntry],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ export const manoeuvreDefinitionSchema = z.object({
|
|||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
weaponCategories: z.array(z.enum(["melee", "ranged"])),
|
weaponCategories: z.array(z.enum(["melee", "ranged"])),
|
||||||
|
minimumLevel: z.number().int().positive().optional(),
|
||||||
shiftCost: z.number().int().optional(),
|
shiftCost: z.number().int().optional(),
|
||||||
disciplineModifier: z.number().int().optional(),
|
disciplineModifier: z.number().int().optional(),
|
||||||
precisionModifier: z.number().int().optional(),
|
precisionModifier: z.number().int().optional(),
|
||||||
@@ -156,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(),
|
||||||
@@ -175,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(),
|
||||||
});
|
});
|
||||||
@@ -204,16 +213,43 @@ export const combatStateSchema = z.object({
|
|||||||
combatLog: z.array(logEntrySchema),
|
combatLog: z.array(logEntrySchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const combatOutcomeStateSchema = z.object({
|
||||||
|
result: z.enum(["victory", "defeat"]),
|
||||||
|
at: z.string().min(1),
|
||||||
|
summary: z.string().min(1),
|
||||||
|
enemyNames: z.array(z.string()),
|
||||||
|
xpAwarded: z.number().int().nonnegative().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const levelUpStateSchema = z.object({
|
||||||
|
previousLevel: z.number().int().positive(),
|
||||||
|
newLevel: z.number().int().positive(),
|
||||||
|
at: z.string().min(1),
|
||||||
|
hpGained: z.number().int().nonnegative(),
|
||||||
|
unlockedManoeuvreIds: z.array(z.string()),
|
||||||
|
summary: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export const runStateSchema = z.object({
|
export const runStateSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
campaignId: z.string().min(1),
|
campaignId: z.string().min(1),
|
||||||
status: z.enum(["active", "paused", "completed", "failed"]),
|
status: z.enum(["active", "paused", "completed", "failed"]),
|
||||||
|
phase: z.enum(["dungeon", "town"]),
|
||||||
startedAt: z.string().min(1),
|
startedAt: z.string().min(1),
|
||||||
|
lastTownAt: z.string().optional(),
|
||||||
currentLevel: z.number().int().positive(),
|
currentLevel: z.number().int().positive(),
|
||||||
currentRoomId: z.string().optional(),
|
currentRoomId: z.string().optional(),
|
||||||
dungeon: dungeonStateSchema,
|
dungeon: dungeonStateSchema,
|
||||||
adventurerSnapshot: adventurerStateSchema,
|
adventurerSnapshot: adventurerStateSchema,
|
||||||
activeCombat: combatStateSchema.optional(),
|
activeCombat: combatStateSchema.optional(),
|
||||||
|
lastCombatOutcome: combatOutcomeStateSchema.optional(),
|
||||||
|
lastLevelUp: levelUpStateSchema.optional(),
|
||||||
|
townState: townStateSchema,
|
||||||
|
defeatedCreatureIds: z.array(z.string()),
|
||||||
|
xpGained: z.number().int().nonnegative(),
|
||||||
|
goldGained: z.number().int().nonnegative(),
|
||||||
|
silverGained: z.number().int().nonnegative(),
|
||||||
|
lootedItems: z.array(inventoryEntrySchema),
|
||||||
log: z.array(logEntrySchema),
|
log: z.array(logEntrySchema),
|
||||||
pendingEffects: z.array(ruleEffectSchema),
|
pendingEffects: z.array(ruleEffectSchema),
|
||||||
});
|
});
|
||||||
|
|||||||
+525
-63
@@ -1,9 +1,10 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: "Segoe UI", "Aptos", sans-serif;
|
font-family: "Trebuchet MS", "Segoe UI", sans-serif;
|
||||||
color: #f3f1e8;
|
color: #f4efe3;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top, rgba(179, 121, 59, 0.35), transparent 30%),
|
radial-gradient(circle at top, rgba(177, 91, 29, 0.25), transparent 26%),
|
||||||
linear-gradient(180deg, #17130f 0%, #0d0b09 100%);
|
radial-gradient(circle at 20% 20%, rgba(227, 188, 101, 0.12), transparent 18%),
|
||||||
|
linear-gradient(180deg, #160f0b 0%, #0b0807 100%);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
@@ -23,108 +24,569 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
width: min(1100px, calc(100% - 2rem));
|
width: min(1200px, calc(100% - 2rem));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 3rem 0 4rem;
|
padding: 1.5rem 0 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
padding: 2rem;
|
display: flex;
|
||||||
border: 1px solid rgba(243, 241, 232, 0.16);
|
justify-content: space-between;
|
||||||
background: rgba(21, 18, 14, 0.72);
|
gap: 1.5rem;
|
||||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35);
|
align-items: end;
|
||||||
backdrop-filter: blur(12px);
|
padding: 1.75rem;
|
||||||
|
border: 1px solid rgba(255, 231, 196, 0.12);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(62, 34, 17, 0.92), rgba(24, 18, 15, 0.86)),
|
||||||
|
rgba(18, 14, 12, 0.9);
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.42);
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.85rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.18em;
|
||||||
color: #d8b27a;
|
color: #f1ba73;
|
||||||
font-size: 0.8rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(2.5rem, 7vw, 4.5rem);
|
font-size: clamp(2.6rem, 6vw, 4.8rem);
|
||||||
line-height: 0.95;
|
line-height: 0.92;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lede {
|
.lede {
|
||||||
width: min(55ch, 100%);
|
width: min(56ch, 100%);
|
||||||
margin: 1.25rem 0 0;
|
margin: 1rem 0 0;
|
||||||
color: rgba(243, 241, 232, 0.82);
|
color: rgba(244, 239, 227, 0.78);
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-grid {
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid rgba(255, 231, 196, 0.12);
|
||||||
|
background: rgba(255, 231, 196, 0.05);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip strong {
|
||||||
|
color: #f7d59d;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-banner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
border: 1px solid rgba(255, 176, 94, 0.3);
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(117, 43, 21, 0.92), rgba(48, 22, 18, 0.92)),
|
||||||
|
rgba(48, 22, 18, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-banner-victory {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(45, 93, 54, 0.92), rgba(26, 46, 29, 0.92)),
|
||||||
|
rgba(26, 46, 29, 0.92);
|
||||||
|
border-color: rgba(125, 219, 150, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-banner-defeat {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(113, 33, 33, 0.92), rgba(48, 18, 18, 0.92)),
|
||||||
|
rgba(48, 18, 18, 0.92);
|
||||||
|
border-color: rgba(232, 123, 123, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-banner-level {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(133, 87, 24, 0.92), rgba(59, 38, 14, 0.92)),
|
||||||
|
rgba(59, 38, 14, 0.92);
|
||||||
|
border-color: rgba(244, 205, 120, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-kicker {
|
||||||
|
display: block;
|
||||||
|
color: #ffbf78;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-banner strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: #fff4de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-banner p {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: rgba(244, 239, 227, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
padding: 1.25rem;
|
grid-column: span 4;
|
||||||
border: 1px solid rgba(243, 241, 232, 0.14);
|
padding: 1.2rem;
|
||||||
background: rgba(29, 24, 19, 0.82);
|
border: 1px solid rgba(255, 231, 196, 0.1);
|
||||||
|
background: rgba(25, 19, 16, 0.86);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 231, 196, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel h2 {
|
.panel-highlight {
|
||||||
margin-top: 0;
|
background:
|
||||||
|
linear-gradient(180deg, rgba(101, 52, 28, 0.28), rgba(25, 19, 16, 0.92)),
|
||||||
|
rgba(25, 19, 16, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-log {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-saves {
|
||||||
|
grid-column: span 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-town-hub {
|
||||||
|
grid-column: span 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #f6d49e;
|
color: #f8d79f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel ul,
|
.panel-header span {
|
||||||
.panel ol {
|
color: rgba(244, 239, 227, 0.58);
|
||||||
margin: 0;
|
font-size: 0.83rem;
|
||||||
padding-left: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel li + li {
|
|
||||||
margin-top: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats div {
|
|
||||||
padding: 0.9rem;
|
|
||||||
background: rgba(243, 241, 232, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats dt {
|
|
||||||
color: rgba(243, 241, 232, 0.62);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats dd {
|
.stat-strip {
|
||||||
margin: 0.3rem 0 0;
|
display: grid;
|
||||||
font-size: 1.7rem;
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
font-weight: 700;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
.stat-strip div,
|
||||||
|
.encounter-box,
|
||||||
|
.combat-status {
|
||||||
|
padding: 0.9rem;
|
||||||
|
background: rgba(255, 245, 223, 0.04);
|
||||||
|
border: 1px solid rgba(255, 231, 196, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-strip span,
|
||||||
|
.encounter-label,
|
||||||
|
.combat-status span,
|
||||||
|
.room-meta span,
|
||||||
|
.log-entry span {
|
||||||
|
display: block;
|
||||||
|
color: rgba(244, 239, 227, 0.56);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-strip strong,
|
||||||
|
.encounter-box strong,
|
||||||
|
.combat-status strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
color: #fff2d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supporting-text {
|
||||||
|
margin: 0.9rem 0 0;
|
||||||
|
color: rgba(244, 239, 227, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.town-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.town-services {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-panel {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid rgba(255, 231, 196, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-card {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgba(255, 231, 196, 0.1);
|
||||||
|
background: rgba(255, 245, 223, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: #fff2d6;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.town-ledger {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid rgba(255, 231, 196, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.town-ledger-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.town-ledger-card {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgba(255, 231, 196, 0.1);
|
||||||
|
background: rgba(255, 245, 223, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.town-ledger-card h3 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: #fff2d6;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.town-service-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgba(255, 231, 196, 0.1);
|
||||||
|
background: rgba(255, 245, 223, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.town-service-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: #fff2d6;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-title {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #fff2d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-top: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row,
|
||||||
|
.enemy-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treasure-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-top: 1px solid rgba(255, 231, 196, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.treasure-row:first-of-type {
|
||||||
|
border-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treasure-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
border: 1px solid rgba(255, 217, 163, 0.24);
|
||||||
|
background: rgba(255, 245, 223, 0.04);
|
||||||
|
color: #f4efe3;
|
||||||
|
padding: 0.72rem 1rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 140ms ease,
|
||||||
|
background 140ms ease,
|
||||||
|
border-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover:enabled {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: rgba(255, 217, 163, 0.09);
|
||||||
|
border-color: rgba(255, 217, 163, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.42;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
background: linear-gradient(180deg, #c36b2d, #8d4617);
|
||||||
|
border-color: rgba(255, 217, 163, 0.32);
|
||||||
|
color: #fff4e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary:hover:enabled {
|
||||||
|
background: linear-gradient(180deg, #d97833, #9f501b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-file {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-file input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.encounter-box,
|
||||||
|
.combat-status {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-feed {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-list,
|
||||||
|
.mini-map,
|
||||||
|
.enemy-list,
|
||||||
|
.save-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-map,
|
||||||
|
.enemy-list {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-card,
|
||||||
|
.map-node,
|
||||||
|
.enemy-card,
|
||||||
|
.save-card {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.95rem;
|
||||||
|
border: 1px solid rgba(255, 231, 196, 0.08);
|
||||||
|
background: rgba(255, 245, 223, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 140ms ease,
|
||||||
|
background 140ms ease,
|
||||||
|
border-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-card:hover:enabled {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: rgba(255, 217, 163, 0.09);
|
||||||
|
border-color: rgba(255, 217, 163, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-card:disabled {
|
||||||
|
opacity: 0.42;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-card span,
|
||||||
|
.map-node span {
|
||||||
|
display: block;
|
||||||
|
color: rgba(244, 239, 227, 0.56);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-card strong,
|
||||||
|
.map-node strong,
|
||||||
|
.enemy-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #fff2d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-card em,
|
||||||
|
.enemy-card span,
|
||||||
|
.save-card span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-style: normal;
|
||||||
|
color: rgba(244, 239, 227, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #fff2d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-node-active {
|
||||||
|
border-color: rgba(243, 186, 115, 0.55);
|
||||||
|
background: rgba(243, 186, 115, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-height: 340px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
padding: 0.9rem;
|
||||||
|
border-left: 3px solid rgba(243, 186, 115, 0.7);
|
||||||
|
background: rgba(255, 245, 223, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry p {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: #f4efe3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.panel,
|
||||||
|
.panel-log {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
width: min(100% - 1rem, 1100px);
|
width: min(100% - 1rem, 1200px);
|
||||||
padding-top: 1rem;
|
padding-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero,
|
.hero,
|
||||||
.panel {
|
.alert-banner {
|
||||||
padding: 1rem;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-strip {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.combat-summary-grid,
|
||||||
|
.town-summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.town-service-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-grid,
|
||||||
|
.town-ledger-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treasure-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export type ManoeuvreDefinition = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
weaponCategories: WeaponCategory[];
|
weaponCategories: WeaponCategory[];
|
||||||
|
minimumLevel?: number;
|
||||||
shiftCost?: number;
|
shiftCost?: number;
|
||||||
disciplineModifier?: number;
|
disciplineModifier?: number;
|
||||||
precisionModifier?: number;
|
precisionModifier?: number;
|
||||||
@@ -154,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 = {
|
||||||
@@ -179,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;
|
||||||
};
|
};
|
||||||
@@ -205,16 +214,43 @@ export type CombatState = {
|
|||||||
combatLog: LogEntry[];
|
combatLog: LogEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CombatOutcomeState = {
|
||||||
|
result: "victory" | "defeat";
|
||||||
|
at: string;
|
||||||
|
summary: string;
|
||||||
|
enemyNames: string[];
|
||||||
|
xpAwarded?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LevelUpState = {
|
||||||
|
previousLevel: number;
|
||||||
|
newLevel: number;
|
||||||
|
at: string;
|
||||||
|
hpGained: number;
|
||||||
|
unlockedManoeuvreIds: string[];
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type RunState = {
|
export type RunState = {
|
||||||
id: string;
|
id: string;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
status: "active" | "paused" | "completed" | "failed";
|
status: "active" | "paused" | "completed" | "failed";
|
||||||
|
phase: "dungeon" | "town";
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
|
lastTownAt?: string;
|
||||||
currentLevel: number;
|
currentLevel: number;
|
||||||
currentRoomId?: string;
|
currentRoomId?: string;
|
||||||
dungeon: DungeonState;
|
dungeon: DungeonState;
|
||||||
adventurerSnapshot: AdventurerState;
|
adventurerSnapshot: AdventurerState;
|
||||||
activeCombat?: CombatState;
|
activeCombat?: CombatState;
|
||||||
|
lastCombatOutcome?: CombatOutcomeState;
|
||||||
|
lastLevelUp?: LevelUpState;
|
||||||
|
townState: TownState;
|
||||||
|
defeatedCreatureIds: string[];
|
||||||
|
xpGained: number;
|
||||||
|
goldGained: number;
|
||||||
|
silverGained: number;
|
||||||
|
lootedItems: InventoryEntry[];
|
||||||
log: LogEntry[];
|
log: LogEntry[];
|
||||||
pendingEffects: RuleEffect[];
|
pendingEffects: RuleEffect[];
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user