From 120e144b3f58c5e10edf49c0fbffd081c7550a56 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 12:54:46 -0500 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8Feature:=20add=20level=201=20conte?= =?UTF-8?q?nt=20including=20room=20templates,=20encounter=20tables,=20and?= =?UTF-8?q?=20room=20generation=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced contentHelpers for table and room template lookups. - Created level1Rooms.ts with various room templates for level 1. - Added level1Tables.ts containing encounter tables for animals, martial, dogs, people, fungal, guards, workers, and room types. - Implemented room generation functions in rooms.ts to create rooms based on templates and tables. - Added tests for room generation logic in rooms.test.ts to ensure correct functionality. --- .gitignore | 1 + src/data/contentHelpers.test.ts | 49 +++ src/data/contentHelpers.ts | 38 ++ src/data/level1Rooms.ts | 649 ++++++++++++++++++++++++++++++++ src/data/level1Tables.ts | 224 +++++++++++ src/data/sampleContentPack.ts | 5 + src/rules/rooms.test.ts | 91 +++++ src/rules/rooms.ts | 198 ++++++++++ src/schemas/content.ts | 4 + src/types/content.ts | 4 + 10 files changed, 1263 insertions(+) create mode 100644 src/data/contentHelpers.test.ts create mode 100644 src/data/contentHelpers.ts create mode 100644 src/data/level1Rooms.ts create mode 100644 src/data/level1Tables.ts create mode 100644 src/rules/rooms.test.ts create mode 100644 src/rules/rooms.ts diff --git a/.gitignore b/.gitignore index 98de87d..99dcbd9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ *.tsbuildinfo vite.config.js vite.config.d.ts +Notes/rendered-pages/ diff --git a/src/data/contentHelpers.test.ts b/src/data/contentHelpers.test.ts new file mode 100644 index 0000000..41c498d --- /dev/null +++ b/src/data/contentHelpers.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { lookupTable } from "@/rules/tables"; + +import { findRoomTemplateForLookup, findTableByCode } from "./contentHelpers"; +import { sampleContentPack } from "./sampleContentPack"; + +function createSequenceRoller(values: number[]) { + let index = 0; + + return () => { + const next = values[index]; + index += 1; + return next; + }; +} + +describe("level 1 content helpers", () => { + it("finds encoded level 1 encounter tables by code", () => { + const table = findTableByCode(sampleContentPack, "L1A"); + + expect(table.name).toBe("Level 1 Animals"); + expect(table.entries).toHaveLength(6); + }); + + it("resolves a small room template from a table lookup", () => { + const lookup = lookupTable(findTableByCode(sampleContentPack, "L1SR"), { + roller: createSequenceRoller([3, 4]), + }); + + const roomTemplate = findRoomTemplateForLookup(sampleContentPack, lookup); + + expect(lookup.roll.total).toBe(7); + expect(roomTemplate.title).toBe("Murtayne Effigy"); + expect(roomTemplate.roomClass).toBe("small"); + }); + + it("resolves a large room template from a table lookup", () => { + const lookup = lookupTable(findTableByCode(sampleContentPack, "L1LR"), { + roller: createSequenceRoller([5, 5]), + }); + + const roomTemplate = findRoomTemplateForLookup(sampleContentPack, lookup); + + expect(lookup.roll.total).toBe(10); + expect(roomTemplate.title).toBe("Slate Shrine"); + expect(roomTemplate.roomClass).toBe("large"); + }); +}); diff --git a/src/data/contentHelpers.ts b/src/data/contentHelpers.ts new file mode 100644 index 0000000..dec59d9 --- /dev/null +++ b/src/data/contentHelpers.ts @@ -0,0 +1,38 @@ +import type { ContentPack, RoomTemplate, TableDefinition } from "@/types/content"; +import type { TableLookupResult } from "@/rules/tables"; + +export function findTableByCode(content: ContentPack, code: string): TableDefinition { + const table = content.tables.find((entry) => entry.code === code); + + if (!table) { + throw new Error(`Unknown table code: ${code}`); + } + + return table; +} + +export function findRoomTemplateById( + content: ContentPack, + roomTemplateId: string, +): RoomTemplate { + const roomTemplate = content.roomTemplates.find((entry) => entry.id === roomTemplateId); + + if (!roomTemplate) { + throw new Error(`Unknown room template id: ${roomTemplateId}`); + } + + return roomTemplate; +} + +export function findRoomTemplateForLookup( + content: ContentPack, + lookup: TableLookupResult, +): RoomTemplate { + const roomReference = lookup.entry.references?.find((reference) => reference.type === "room"); + + if (!roomReference) { + throw new Error(`Lookup result ${lookup.tableId}:${lookup.entryKey} does not point to a room.`); + } + + return findRoomTemplateById(content, roomReference.id); +} diff --git a/src/data/level1Rooms.ts b/src/data/level1Rooms.ts new file mode 100644 index 0000000..169d26c --- /dev/null +++ b/src/data/level1Rooms.ts @@ -0,0 +1,649 @@ +import type { RoomTemplate } from "@/types/content"; + +export const level1RoomTemplates: RoomTemplate[] = [ + { + id: "room.level1.normal.empty-room", + level: 1, + sourcePage: 42, + roomClass: "normal", + tableCode: "L1R-P1", + tableEntryKey: "2", + title: "Empty Room", + text: "A bare chamber with little more than dust and stone.", + encounterText: "Nothing here.", + exitHint: "Archways only.", + unique: false, + tags: ["human-ancestry", "empty"], + mvp: true, + }, + { + id: "room.level1.normal.abandoned-guard-post", + level: 1, + sourcePage: 42, + roomClass: "normal", + tableCode: "L1R-P1", + tableEntryKey: "3", + title: "Abandoned Guard Post", + text: "A disused guard station with ruined furnishings and signs of looting.", + encounterText: "Search debris for wooden objects beneath collapsed furniture.", + exitHint: "Wooden doors.", + unique: false, + tags: ["human-ancestry", "search"], + mvp: true, + }, + { + id: "room.level1.normal.guard-post", + level: 1, + sourcePage: 42, + roomClass: "normal", + tableCode: "L1R-P1", + tableEntryKey: "4", + title: "Guard Post", + text: "A still-used guard room with a table, benches, and simple fixtures.", + encounterText: "If occupied, use the martial encounter table.", + exitHint: "Reinforced wooden doors.", + unique: false, + tags: ["human-ancestry", "guards"], + mvp: true, + }, + { + id: "room.level1.normal.storage-area", + level: 1, + sourcePage: 42, + roomClass: "normal", + tableCode: "L1R-P1", + tableEntryKey: "5", + title: "Storage Area", + text: "Crates and barrels fill the walls, with spoiled goods hidden among them.", + encounterText: "Roll for food, drink, or crate events while searching.", + exitHint: "Archways.", + unique: false, + tags: ["human-ancestry", "loot"], + mvp: true, + }, + { + id: "room.level1.normal.meeting-room", + level: 1, + sourcePage: 42, + roomClass: "normal", + tableCode: "L1R-P1", + tableEntryKey: "6", + title: "Meeting Room", + text: "Simple tables and chairs suggest an old gathering place.", + encounterText: "Workers or a crazed preacher may be present depending on the table roll.", + exitHint: "Wooden doors.", + unique: true, + tags: ["human-ancestry", "social"], + mvp: true, + }, + { + id: "room.level1.normal.blacksmith-space", + level: 1, + sourcePage: 42, + roomClass: "normal", + tableCode: "L1R-P1", + tableEntryKey: "7", + title: "Blacksmith Space", + text: "An anvil and cold forge remain in a soot-darkened workshop.", + encounterText: "Roll for weapon loot or a worker-themed encounter.", + exitHint: "Archways.", + unique: false, + tags: ["human-ancestry", "craft"], + mvp: true, + }, + { + id: "room.level1.normal.holding-cell", + level: 1, + sourcePage: 42, + roomClass: "normal", + tableCode: "L1R-P1", + tableEntryKey: "8", + title: "Holding Cell", + text: "A grim cell with chains, a chamber pot, and signs of former prisoners.", + encounterText: "Check whether the cell is empty or contains a prisoner.", + exitHint: "Reinforced doors.", + unique: false, + tags: ["human-ancestry", "cell"], + mvp: true, + }, + { + id: "room.level1.normal.wash-room", + level: 1, + sourcePage: 42, + roomClass: "normal", + tableCode: "L1R-P1", + tableEntryKey: "9", + title: "Wash Room", + text: "Buckets, basins, and cloths suggest a communal cleaning room.", + encounterText: "May contain slimy or fungal threats and hidden salvage.", + exitHint: "Random roll for exits.", + unique: false, + tags: ["human-ancestry", "utility"], + mvp: true, + }, + { + id: "room.level1.normal.kennel", + level: 1, + sourcePage: 42, + roomClass: "normal", + tableCode: "L1R-P1", + tableEntryKey: "10", + title: "Kennel", + text: "A low room littered with straw and the stink of animals.", + encounterText: "Use the level 1 dogs table if occupied.", + exitHint: "Wooden doors.", + unique: false, + tags: ["human-ancestry", "animals"], + mvp: true, + }, + { + id: "room.level1.normal.snake-pit", + level: 1, + sourcePage: 42, + roomClass: "normal", + tableCode: "L1R-P1", + tableEntryKey: "11", + title: "Snake Pit", + text: "A dusty pit cut into the floor houses a nest of snakes.", + encounterText: "Open the chest only if you survive the pit's occupants.", + exitHint: "Random roll for exits.", + unique: false, + tags: ["human-ancestry", "animals", "hazard"], + mvp: true, + }, + { + id: "room.level1.normal.weapon-dump", + level: 1, + sourcePage: 42, + roomClass: "normal", + tableCode: "L1R-P1", + tableEntryKey: "12", + title: "Weapon Dump", + text: "Broken weapons and shields spill from a long-past discard pile.", + encounterText: "Roll for random weapons and possible fungal attackers.", + exitHint: "Wooden doors.", + unique: false, + tags: ["human-ancestry", "loot", "weapons"], + mvp: true, + }, + { + id: "room.level1.normal.guard-room", + level: 1, + sourcePage: 43, + roomClass: "normal", + tableCode: "L1R-P2", + tableEntryKey: "2", + title: "Guard Room", + text: "A stone room with table, chairs, and signs of routine occupation.", + encounterText: "Use the guards encounter table if occupied.", + exitHint: "Wooden doors.", + unique: false, + tags: ["human-ancestry", "guards"], + mvp: true, + }, + { + id: "room.level1.normal.pool-room", + level: 1, + sourcePage: 43, + roomClass: "normal", + tableCode: "L1R-P2", + tableEntryKey: "3", + title: "Pool Room", + text: "A raised stone pool dominates the center of this damp chamber.", + encounterText: "Investigating the pool may trigger a slimy or animal threat.", + exitHint: "Wooden doors.", + unique: true, + tags: ["human-ancestry", "water"], + mvp: true, + }, + { + id: "room.level1.normal.storage-area-2", + level: 1, + sourcePage: 43, + roomClass: "normal", + tableCode: "L1R-P2", + tableEntryKey: "4", + title: "Storage Area", + text: "Ropes, cloth, and supplies are stacked among the dust.", + encounterText: "Arms and empty chests invite a guarded search.", + exitHint: "Archways.", + unique: false, + tags: ["human-ancestry", "loot"], + mvp: true, + }, + { + id: "room.level1.normal.canteen", + level: 1, + sourcePage: 43, + roomClass: "normal", + tableCode: "L1R-P2", + tableEntryKey: "5", + title: "Canteen", + text: "Rough tables and stools fill an old communal eating space.", + encounterText: "Labourers or workers may still be here.", + exitHint: "Wooden doors.", + unique: false, + tags: ["human-ancestry", "social"], + mvp: true, + }, + { + id: "room.level1.normal.mourning-quarters", + level: 1, + sourcePage: 43, + roomClass: "normal", + tableCode: "L1R-P2", + tableEntryKey: "6", + title: "Mourning Quarters", + text: "An eerie chamber of candles, a shrouded bier, and stale grief.", + encounterText: "A corpse may be present and can trigger a level 1 corpse interaction.", + exitHint: "Random roll for exits.", + unique: true, + tags: ["human-ancestry", "ritual"], + mvp: true, + }, + { + id: "room.level1.normal.holding-cell-2", + level: 1, + sourcePage: 43, + roomClass: "normal", + tableCode: "L1R-P2", + tableEntryKey: "7", + title: "Holding Cell", + text: "A bare cell cut off from the rest of the room by bars.", + encounterText: "Check for a prisoner before searching further.", + exitHint: "Reinforced doors.", + unique: false, + tags: ["human-ancestry", "cell"], + mvp: true, + }, + { + id: "room.level1.normal.training-room", + level: 1, + sourcePage: 43, + roomClass: "normal", + tableCode: "L1R-P2", + tableEntryKey: "8", + title: "Training Room", + text: "Mannequins, target poles, and old practice gear fill the space.", + encounterText: "Armour and weapons training prompts a follow-up armour or item roll.", + exitHint: "Random roll for exits.", + unique: false, + tags: ["human-ancestry", "training"], + mvp: true, + }, + { + id: "room.level1.normal.dorm", + level: 1, + sourcePage: 43, + roomClass: "normal", + tableCode: "L1R-P2", + tableEntryKey: "9", + title: "Dorm", + text: "Hammocks and scattered belongings suggest a once-lived-in barracks room.", + encounterText: "Use the people table if the dorm is occupied.", + exitHint: "Wooden doors.", + unique: false, + tags: ["human-ancestry", "sleeping"], + mvp: true, + }, + { + id: "room.level1.normal.apothecary", + level: 1, + sourcePage: 43, + roomClass: "normal", + tableCode: "L1R-P2", + tableEntryKey: "10", + title: "Apothecary", + text: "Shelves of jars and bottles crowd a room thick with old herbs.", + encounterText: "Medicine or potion finds compete with apothecary occupants.", + exitHint: "Random roll for exits.", + unique: true, + tags: ["human-ancestry", "alchemy"], + mvp: true, + }, + { + id: "room.level1.normal.damp-space", + level: 1, + sourcePage: 43, + roomClass: "normal", + tableCode: "L1R-P2", + tableEntryKey: "11", + title: "Damp Space", + text: "Moist stone and fungus overrun this clammy side room.", + encounterText: "A fungal encounter may attack when the fungus is disturbed.", + exitHint: "Reinforced doors.", + unique: false, + tags: ["human-ancestry", "fungal"], + mvp: true, + }, + { + id: "room.level1.normal.chapel", + level: 1, + sourcePage: 43, + roomClass: "normal", + tableCode: "L1R-P2", + tableEntryKey: "12", + title: "Chapel", + text: "Candles, benches, and a central altar mark a room of devotion.", + encounterText: "Dark clergy or a corpse event can emerge here after the altar is examined.", + exitHint: "Curtains.", + unique: true, + tags: ["human-ancestry", "religious"], + mvp: true, + }, + { + id: "room.level1.large.stone-workshop", + level: 1, + sourcePage: 40, + roomClass: "large", + tableCode: "L1LR", + tableEntryKey: "2", + title: "Stone Workshop", + text: "Large tables, stone shelves, and an oversized work area dominate the room.", + encounterText: "No built-in encounter.", + exitHint: "Wooden doors.", + tags: ["human-ancestry", "large", "craft"], + mvp: true, + }, + { + id: "room.level1.large.marble-hall", + level: 1, + sourcePage: 40, + roomClass: "large", + tableCode: "L1LR", + tableEntryKey: "3", + title: "Marble Hall", + text: "A ceremonial hall lined with pillars and a raised seating platform.", + encounterText: "No built-in encounter.", + exitHint: "Archways.", + unique: true, + tags: ["human-ancestry", "large", "hall"], + mvp: true, + }, + { + id: "room.level1.large.old-mess-hall", + level: 1, + sourcePage: 40, + roomClass: "large", + tableCode: "L1LR", + tableEntryKey: "4", + title: "Old Mess Hall", + text: "Long tables and benches suggest a once-busy common dining area.", + encounterText: "No built-in encounter.", + exitHint: "Wooden doors.", + unique: true, + tags: ["human-ancestry", "large", "social"], + mvp: true, + }, + { + id: "room.level1.large.penitentiary", + level: 1, + sourcePage: 40, + roomClass: "large", + tableCode: "L1LR", + tableEntryKey: "5", + title: "Penitentiary", + text: "A chained whipping post and harsh punishments define this grim chamber.", + encounterText: "No built-in encounter.", + exitHint: "Reinforced doors.", + unique: true, + tags: ["human-ancestry", "large", "punishment"], + mvp: true, + }, + { + id: "room.level1.large.fountain-room", + level: 1, + sourcePage: 40, + roomClass: "large", + tableCode: "L1LR", + tableEntryKey: "6", + title: "Fountain Room", + text: "A circular fountain and polished floor lend this chamber a sacred tone.", + encounterText: "No built-in encounter.", + exitHint: "Archways.", + tags: ["human-ancestry", "large", "water"], + mvp: true, + }, + { + id: "room.level1.large.temple", + level: 1, + sourcePage: 40, + roomClass: "large", + tableCode: "L1LR", + tableEntryKey: "7", + title: "Temple", + text: "Benches and chandeliers frame a formal place of worship.", + encounterText: "No built-in encounter.", + exitHint: "Archways.", + unique: true, + tags: ["human-ancestry", "large", "religious"], + mvp: true, + }, + { + id: "room.level1.large.sparring-chamber", + level: 1, + sourcePage: 40, + roomClass: "large", + tableCode: "L1LR", + tableEntryKey: "8", + title: "Sparring Chamber", + text: "Sand, markings, and weapons racks show this room was used for training.", + encounterText: "A warrior can appear here.", + exitHint: "Wooden doors.", + tags: ["human-ancestry", "large", "training"], + mvp: true, + }, + { + id: "room.level1.large.crate-store", + level: 1, + sourcePage: 40, + roomClass: "large", + tableCode: "L1LR", + tableEntryKey: "9", + title: "Crate Store", + text: "Crates and stacked stone create shadows and possible hidden spaces.", + encounterText: "No built-in encounter.", + exitHint: "Archways.", + tags: ["human-ancestry", "large", "loot"], + mvp: true, + }, + { + id: "room.level1.large.slate-shrine", + level: 1, + sourcePage: 40, + roomClass: "large", + tableCode: "L1LR", + tableEntryKey: "10", + title: "Slate Shrine", + text: "A central slate monolith marks a room of devotion and offering.", + encounterText: "No built-in encounter.", + exitHint: "Archways.", + unique: true, + tags: ["human-ancestry", "large", "religious"], + mvp: true, + }, + { + id: "room.level1.large.dormitory", + level: 1, + sourcePage: 40, + roomClass: "large", + tableCode: "L1LR", + tableEntryKey: "11", + title: "Dormitory", + text: "Rows of bunks and sparse belongings line the walls.", + encounterText: "No built-in encounter.", + exitHint: "Wooden doors.", + unique: true, + tags: ["human-ancestry", "large", "sleeping"], + mvp: true, + }, + { + id: "room.level1.large.library", + level: 1, + sourcePage: 40, + roomClass: "large", + tableCode: "L1LR", + tableEntryKey: "12", + title: "Library", + text: "Towering bookshelves and guarded knowledge define this vast archive.", + encounterText: "Two guards can be found here.", + exitHint: "Wooden doors.", + unique: true, + tags: ["human-ancestry", "large", "books"], + mvp: true, + }, + { + id: "room.level1.small.empty-space", + level: 1, + sourcePage: 46, + roomClass: "small", + tableCode: "L1SR", + tableEntryKey: "2", + title: "Empty Space", + text: "A bare side space with nothing of note.", + encounterText: "Nothing here.", + unique: false, + tags: ["human-ancestry", "small", "empty"], + mvp: true, + }, + { + id: "room.level1.small.strange-text", + level: 1, + sourcePage: 46, + roomClass: "small", + tableCode: "L1SR", + tableEntryKey: "3", + title: "Strange Text", + text: "An old message linking the corridor to ancient hunger and warning.", + encounterText: "No encounter.", + unique: false, + tags: ["human-ancestry", "small", "lore"], + mvp: true, + }, + { + id: "room.level1.small.grazada-mural", + level: 1, + sourcePage: 46, + roomClass: "small", + tableCode: "L1SR", + tableEntryKey: "4", + title: "Grazada Mural", + text: "A tiled image of the goddess of sacrifice fills the wall.", + encounterText: "A possible favour interaction.", + unique: true, + tags: ["human-ancestry", "small", "religious"], + mvp: true, + }, + { + id: "room.level1.small.intuneric-mosaic", + level: 1, + sourcePage: 46, + roomClass: "small", + tableCode: "L1SR", + tableEntryKey: "5", + title: "Intuneric Mosaic", + text: "A shrine mosaic to the god of influence and gifts.", + encounterText: "A possible favour interaction.", + unique: true, + tags: ["human-ancestry", "small", "religious"], + mvp: true, + }, + { + id: "room.level1.small.maduva-statue", + level: 1, + sourcePage: 46, + roomClass: "small", + tableCode: "L1SR", + tableEntryKey: "6", + title: "Maduva Statue", + text: "A twisted statue of Maduva stands watch in the niche.", + encounterText: "A possible favour interaction.", + unique: true, + tags: ["human-ancestry", "small", "religious"], + mvp: true, + }, + { + id: "room.level1.small.murtayne-effigy", + level: 1, + sourcePage: 46, + roomClass: "small", + tableCode: "L1SR", + tableEntryKey: "7", + title: "Murtayne Effigy", + text: "A rotting fleshy effigy marks a place linked to disease and corruption.", + encounterText: "A possible favour interaction.", + unique: true, + tags: ["human-ancestry", "small", "religious"], + mvp: true, + }, + { + id: "room.level1.small.nevzator-doll", + level: 1, + sourcePage: 46, + roomClass: "small", + tableCode: "L1SR", + tableEntryKey: "8", + title: "Nevzator Doll", + text: "A rope doll symbolizes hidden dealings and quiet bargains.", + encounterText: "A possible favour interaction.", + unique: true, + tags: ["human-ancestry", "small", "religious"], + mvp: true, + }, + { + id: "room.level1.small.radacina-tapestry", + level: 1, + sourcePage: 46, + roomClass: "small", + tableCode: "L1SR", + tableEntryKey: "9", + title: "Radacina Tapestry", + text: "A tapestry of Radacina, deity of teaching, hangs in the space.", + encounterText: "A possible favour interaction.", + unique: true, + tags: ["human-ancestry", "small", "religious"], + mvp: true, + }, + { + id: "room.level1.small.heated-space", + level: 1, + sourcePage: 46, + roomClass: "small", + tableCode: "L1SR", + tableEntryKey: "10", + title: "Heated Space", + text: "Warmth and wavering shadows suggest hidden heat behind the walls.", + encounterText: "No encounter.", + unique: false, + tags: ["human-ancestry", "small", "atmosphere"], + mvp: true, + }, + { + id: "room.level1.small.wall-shrine", + level: 1, + sourcePage: 46, + roomClass: "small", + tableCode: "L1SR", + tableEntryKey: "11", + title: "Wall Shrine", + text: "A small offering niche waits at the wall's edge.", + encounterText: "A possible favour interaction.", + unique: false, + tags: ["human-ancestry", "small", "religious"], + mvp: true, + }, + { + id: "room.level1.small.banner-arms", + level: 1, + sourcePage: 46, + roomClass: "small", + tableCode: "L1SR", + tableEntryKey: "12", + title: "Banner Arms", + text: "Crossed spears and an old shield suggest a ceremonial martial display.", + encounterText: "No encounter.", + unique: false, + tags: ["human-ancestry", "small", "martial"], + mvp: true, + }, +]; diff --git a/src/data/level1Tables.ts b/src/data/level1Tables.ts new file mode 100644 index 0000000..5677517 --- /dev/null +++ b/src/data/level1Tables.ts @@ -0,0 +1,224 @@ +import type { TableDefinition } from "@/types/content"; + +export const level1EncounterTables: TableDefinition[] = [ + { + id: "table.level1.animals", + code: "L1A", + name: "Level 1 Animals", + category: "level", + level: 1, + page: 38, + diceKind: "d6", + entries: [ + { key: "1", exact: 1, label: "Giant Rat" }, + { key: "2", exact: 2, label: "Huge Rat" }, + { key: "3", exact: 3, label: "Work Dog" }, + { key: "4", exact: 4, label: "Guard Dog" }, + { key: "5", exact: 5, label: "Shard Rock Python" }, + { key: "6", exact: 6, label: "Giant Horned Anaconda" }, + ], + mvp: true, + }, + { + id: "table.level1.martial", + code: "L1M", + name: "Level 1 Martial", + category: "level", + level: 1, + page: 38, + diceKind: "d6", + entries: [ + { key: "1", exact: 1, label: "Guard" }, + { key: "2", exact: 2, label: "Guard" }, + { key: "3", exact: 3, label: "Warrior" }, + { key: "4", exact: 4, label: "Veteran" }, + { key: "5", exact: 5, label: "Veteran" }, + { key: "6", exact: 6, label: "Guard and Guard" }, + ], + mvp: true, + }, + { + id: "table.level1.dogs", + code: "L1D", + name: "Level 1 Dogs", + category: "level", + level: 1, + page: 38, + diceKind: "d6", + entries: [ + { key: "1", exact: 1, label: "Work Dog" }, + { key: "2", exact: 2, label: "Guard Dog" }, + { key: "3", exact: 3, label: "Guard Dog" }, + { key: "4", exact: 4, label: "Guard Hound" }, + { key: "5", exact: 5, label: "War Hound" }, + { key: "6", exact: 6, label: "Guard and Work Dog" }, + ], + mvp: true, + }, + { + id: "table.level1.people", + code: "L1P", + name: "Level 1 People", + category: "level", + level: 1, + page: 38, + diceKind: "d6", + entries: [ + { key: "1", exact: 1, label: "Labourer" }, + { key: "2", exact: 2, label: "Crazed Preacher" }, + { key: "3", exact: 3, label: "Thug" }, + { key: "4", exact: 4, label: "Guard" }, + { key: "5", exact: 5, label: "Guard and Guard Dog" }, + { key: "6", exact: 6, label: "Labourer and Guard Dog" }, + ], + mvp: true, + }, + { + id: "table.level1.fungal", + code: "L1F", + name: "Level 1 Fungal", + category: "level", + level: 1, + page: 38, + diceKind: "d6", + entries: [ + { key: "1", exact: 1, label: "Fungal Geist" }, + { key: "2", exact: 2, label: "Fungal Geist" }, + { key: "3", exact: 3, label: "Musty Bloater" }, + { key: "4", exact: 4, label: "Musty Bloater" }, + { key: "5", exact: 5, label: "Musty Bloater" }, + { key: "6", exact: 6, label: "Slimy Gorger" }, + ], + mvp: true, + }, + { + id: "table.level1.guards", + code: "L1G", + name: "Level 1 Guards", + category: "level", + level: 1, + page: 38, + diceKind: "d6", + entries: [ + { key: "1", exact: 1, label: "Thug" }, + { key: "2", exact: 2, label: "Thug" }, + { key: "3", exact: 3, label: "Guard" }, + { key: "4", exact: 4, label: "Guard" }, + { key: "5", exact: 5, label: "Guard" }, + { key: "6", exact: 6, label: "Guard and Warrior" }, + ], + mvp: true, + }, + { + id: "table.level1.workers", + code: "L1W", + name: "Level 1 Workers", + category: "level", + level: 1, + page: 38, + diceKind: "d6", + entries: [ + { key: "1", exact: 1, label: "Thug" }, + { key: "2", exact: 2, label: "Thug" }, + { key: "3", exact: 3, label: "Labourer" }, + { key: "4", exact: 4, label: "Artisan" }, + { key: "5", exact: 5, label: "Blacksmith" }, + { key: "6", exact: 6, label: "Artisan and Medic" }, + ], + mvp: true, + }, + { + id: "table.level1.crate", + code: "L1CE", + name: "Level 1 Crate", + category: "level", + level: 1, + page: 38, + diceKind: "d6", + entries: [ + { + key: "1", + exact: 1, + label: "Slimy Gorger Ambush", + text: "A slimy gorger drops from above and attacks immediately.", + }, + { + key: "2", + exact: 2, + label: "Giant Rat Pair", + text: "A pair of giant rats attacks while the room is being searched.", + }, + { + key: "3", + exact: 3, + label: "Huge Rat Ambush", + text: "A huge rat leaps from under the crate and attacks.", + }, + { + key: "4", + exact: 4, + label: "Insect Swarm", + text: "Scuttling insects spill out from beneath a corpse during the search.", + }, + { + key: "5", + exact: 5, + label: "Corpse Trap", + text: "Searching the fallen body triggers a level 1 corpse-interaction event.", + }, + { + key: "6", + exact: 6, + label: "Hooked Bag", + text: "A bag fixed near the ceiling calls for a follow-up item table roll.", + }, + ], + mvp: true, + }, + { + id: "table.level1.large-rooms", + code: "L1LR", + name: "Level 1 Large Rooms", + category: "room", + level: 1, + page: 40, + diceKind: "2d6", + entries: [ + { key: "2", exact: 2, label: "Stone Workshop", references: [{ type: "room", id: "room.level1.large.stone-workshop" }] }, + { key: "3", exact: 3, label: "Marble Hall", references: [{ type: "room", id: "room.level1.large.marble-hall" }] }, + { key: "4", exact: 4, label: "Old Mess Hall", references: [{ type: "room", id: "room.level1.large.old-mess-hall" }] }, + { key: "5", exact: 5, label: "Penitentiary", references: [{ type: "room", id: "room.level1.large.penitentiary" }] }, + { key: "6", exact: 6, label: "Fountain Room", references: [{ type: "room", id: "room.level1.large.fountain-room" }] }, + { key: "7", exact: 7, label: "Temple", references: [{ type: "room", id: "room.level1.large.temple" }] }, + { key: "8", exact: 8, label: "Sparring Chamber", references: [{ type: "room", id: "room.level1.large.sparring-chamber" }] }, + { key: "9", exact: 9, label: "Crate Store", references: [{ type: "room", id: "room.level1.large.crate-store" }] }, + { key: "10", exact: 10, label: "Slate Shrine", references: [{ type: "room", id: "room.level1.large.slate-shrine" }] }, + { key: "11", exact: 11, label: "Dormitory", references: [{ type: "room", id: "room.level1.large.dormitory" }] }, + { key: "12", exact: 12, label: "Library", references: [{ type: "room", id: "room.level1.large.library" }] }, + ], + mvp: true, + }, + { + id: "table.level1.small-rooms", + code: "L1SR", + name: "Level 1 Small Rooms", + category: "room", + level: 1, + page: 46, + diceKind: "2d6", + entries: [ + { key: "2", exact: 2, label: "Empty Space", references: [{ type: "room", id: "room.level1.small.empty-space" }] }, + { key: "3", exact: 3, label: "Strange Text", references: [{ type: "room", id: "room.level1.small.strange-text" }] }, + { key: "4", exact: 4, label: "Grazada Mural", references: [{ type: "room", id: "room.level1.small.grazada-mural" }] }, + { key: "5", exact: 5, label: "Intuneric Mosaic", references: [{ type: "room", id: "room.level1.small.intuneric-mosaic" }] }, + { key: "6", exact: 6, label: "Maduva Statue", references: [{ type: "room", id: "room.level1.small.maduva-statue" }] }, + { key: "7", exact: 7, label: "Murtayne Effigy", references: [{ type: "room", id: "room.level1.small.murtayne-effigy" }] }, + { key: "8", exact: 8, label: "Nevzator Doll", references: [{ type: "room", id: "room.level1.small.nevzator-doll" }] }, + { key: "9", exact: 9, label: "Radacina Tapestry", references: [{ type: "room", id: "room.level1.small.radacina-tapestry" }] }, + { key: "10", exact: 10, label: "Heated Space", references: [{ type: "room", id: "room.level1.small.heated-space" }] }, + { key: "11", exact: 11, label: "Wall Shrine", references: [{ type: "room", id: "room.level1.small.wall-shrine" }] }, + { key: "12", exact: 12, label: "Banner Arms", references: [{ type: "room", id: "room.level1.small.banner-arms" }] }, + ], + mvp: true, + }, +]; diff --git a/src/data/sampleContentPack.ts b/src/data/sampleContentPack.ts index 6792dee..06e3393 100644 --- a/src/data/sampleContentPack.ts +++ b/src/data/sampleContentPack.ts @@ -1,6 +1,8 @@ import type { ContentPack } from "@/types/content"; import { contentPackSchema } from "@/schemas/content"; +import { level1RoomTemplates } from "./level1Rooms"; +import { level1EncounterTables } from "./level1Tables"; const samplePack = { version: "0.1.0", @@ -10,6 +12,7 @@ const samplePack = { "Source/2D6 Dungeon - Play Sheet.pdf", ], tables: [ + ...level1EncounterTables, { id: "table.weapon-manoeuvres-1", code: "WMT1", @@ -169,6 +172,7 @@ const samplePack = { { id: "room.level1.entry", level: 1, + sourcePage: 42, roomClass: "start", tableCode: "L1R", tableEntryKey: "entry", @@ -182,6 +186,7 @@ const samplePack = { tags: ["starter", "entry"], mvp: true, }, + ...level1RoomTemplates, ], townServices: [ { diff --git a/src/rules/rooms.test.ts b/src/rules/rooms.test.ts new file mode 100644 index 0000000..60aa2fb --- /dev/null +++ b/src/rules/rooms.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { + createLevelShell, + createRoomStateFromTemplate, + generateLevel1StartRoom, + generateRoomFromTable, +} from "./rooms"; + +function createSequenceRoller(values: number[]) { + let index = 0; + + return () => { + const next = values[index]; + index += 1; + return next; + }; +} + +describe("room generation", () => { + it("creates the level 1 start room from its template", () => { + const result = generateLevel1StartRoom(sampleContentPack); + + expect(result.templateSource).toBe("direct-template"); + expect(result.room.roomClass).toBe("start"); + expect(result.room.position).toEqual({ x: 0, y: 0 }); + expect(result.room.exits).toHaveLength(1); + expect(result.room.exits[0]?.direction).toBe("north"); + }); + + it("generates a small room from the encoded small-room table", () => { + const result = generateRoomFromTable({ + content: sampleContentPack, + roomId: "room.level1.small.001", + level: 1, + roomTableCode: "L1SR", + position: { x: 2, y: 1 }, + roller: createSequenceRoller([5, 5]), + }); + + expect(result.lookup?.roll.total).toBe(10); + expect(result.room.templateId).toBe("room.level1.small.heated-space"); + expect(result.room.dimensions).toEqual({ width: 2, height: 3 }); + expect(result.room.exits).toHaveLength(1); + }); + + it("generates a large room from the encoded large-room table", () => { + const result = generateRoomFromTable({ + content: sampleContentPack, + roomId: "room.level1.large.001", + level: 1, + roomTableCode: "L1LR", + position: { x: 4, y: 3 }, + roller: createSequenceRoller([4, 4]), + }); + + expect(result.lookup?.roll.total).toBe(8); + expect(result.room.templateId).toBe("room.level1.large.sparring-chamber"); + expect(result.room.dimensions).toEqual({ width: 6, height: 6 }); + expect(result.room.exits).toHaveLength(3); + expect(result.room.flags).toContain("large"); + }); + + it("respects explicit template exits when they are defined", () => { + const room = createRoomStateFromTemplate( + sampleContentPack, + "room.level1.custom.start", + 1, + "room.level1.entry", + { x: 0, y: 0 }, + ); + + expect(room.exits).toEqual([ + expect.objectContaining({ + direction: "north", + exitType: "door", + discovered: true, + }), + ]); + }); + + it("creates a clean level shell for future dungeon state", () => { + const level = createLevelShell(1); + + expect(level.themeName).toBe("The Entry"); + expect(level.rooms).toEqual({}); + expect(level.discoveredRoomOrder).toEqual([]); + }); +}); diff --git a/src/rules/rooms.ts b/src/rules/rooms.ts new file mode 100644 index 0000000..ed65474 --- /dev/null +++ b/src/rules/rooms.ts @@ -0,0 +1,198 @@ +import { + findRoomTemplateById, + findRoomTemplateForLookup, + findTableByCode, +} from "@/data/contentHelpers"; +import type { ContentPack, ExitType, RoomClass } from "@/types/content"; +import type { RoomExitState, RoomState } from "@/types/state"; + +import { lookupTable, type TableLookupResult } from "./tables"; +import type { DiceRoller } from "./dice"; + +export type RoomGenerationOptions = { + content: ContentPack; + roomId: string; + level: number; + roomTemplateId?: string; + roomTableCode?: string; + roomClass?: RoomClass; + position?: { + x: number; + y: number; + }; + roller?: DiceRoller; +}; + +export type RoomGenerationResult = { + room: RoomState; + lookup?: TableLookupResult; + templateSource: "direct-template" | "table-lookup"; +}; + +const DEFAULT_ROOM_DIMENSIONS: Record = { + start: { width: 4, height: 4 }, + normal: { width: 4, height: 4 }, + small: { width: 2, height: 3 }, + large: { width: 6, height: 6 }, + special: { width: 4, height: 4 }, + stairs: { width: 4, height: 4 }, +}; + +const DEFAULT_DIRECTIONS = ["north", "east", "south", "west"] as const; + +function inferExitType(exitHint?: string): ExitType { + const normalized = exitHint?.toLowerCase() ?? ""; + + if (normalized.includes("reinforced")) { + return "locked"; + } + + if (normalized.includes("curtain")) { + return "open"; + } + + if (normalized.includes("archway")) { + return "open"; + } + + if (normalized.includes("wooden")) { + return "door"; + } + + return "door"; +} + +function inferExitCount(roomClass: RoomClass, exitHint?: string) { + const normalized = exitHint?.toLowerCase() ?? ""; + + if (normalized.includes("random")) { + return roomClass === "large" ? 3 : 2; + } + + if (normalized.includes("archway")) { + return roomClass === "large" ? 3 : 2; + } + + if (roomClass === "small") { + return 1; + } + + if (roomClass === "large") { + return 3; + } + + return 2; +} + +function createExits( + roomId: string, + roomClass: RoomClass, + exitHint?: string, +): RoomExitState[] { + const exitCount = inferExitCount(roomClass, exitHint); + const exitType = inferExitType(exitHint); + + return DEFAULT_DIRECTIONS.slice(0, exitCount).map((direction, index) => ({ + id: `${roomId}.exit.${index + 1}`, + direction, + exitType, + discovered: roomClass === "start", + traversable: true, + })); +} + +export function createRoomStateFromTemplate( + content: ContentPack, + roomId: string, + level: number, + roomTemplateId: string, + position = { x: 0, y: 0 }, +): RoomState { + const template = findRoomTemplateById(content, roomTemplateId); + const dimensions = template.dimensions ?? DEFAULT_ROOM_DIMENSIONS[template.roomClass]; + const exits = + template.exits?.map((exit, index) => ({ + id: `${roomId}.exit.${index + 1}`, + direction: exit.direction ?? DEFAULT_DIRECTIONS[index] ?? "north", + exitType: exit.exitType, + discovered: template.roomClass === "start", + traversable: exit.exitType !== "locked", + destinationLevel: exit.destinationLevel, + })) ?? createExits(roomId, template.roomClass, template.exitHint); + + return { + id: roomId, + level, + templateId: template.id, + position, + dimensions, + roomClass: template.roomClass, + exits, + discovery: { + generated: true, + entered: template.roomClass === "start", + cleared: false, + searched: false, + }, + encounter: undefined, + objects: [], + notes: [template.text ?? template.title].filter(Boolean), + flags: [ + `table:${template.tableCode}`, + `entry:${template.tableEntryKey}`, + ...(template.unique ? ["unique"] : []), + ...template.tags, + ], + }; +} + +export function generateRoomFromTable( + options: RoomGenerationOptions, +): RoomGenerationResult { + if (!options.roomTableCode) { + throw new Error("Room table code is required for table-based room generation."); + } + + const table = findTableByCode(options.content, options.roomTableCode); + const lookup = lookupTable(table, { roller: options.roller }); + const template = findRoomTemplateForLookup(options.content, lookup); + const room = createRoomStateFromTemplate( + options.content, + options.roomId, + options.level, + template.id, + options.position, + ); + + return { + room, + lookup, + templateSource: "table-lookup", + }; +} + +export function generateLevel1StartRoom( + content: ContentPack, + roomId = "room.level1.start", +): RoomGenerationResult { + const room = createRoomStateFromTemplate(content, roomId, 1, "room.level1.entry", { + x: 0, + y: 0, + }); + + return { + room, + templateSource: "direct-template", + }; +} + +export function createLevelShell(level: number) { + return { + level, + themeName: level === 1 ? "The Entry" : undefined, + rooms: {}, + discoveredRoomOrder: [], + secretDoorUsed: false, + exhaustedExitSearch: false, + }; +} diff --git a/src/schemas/content.ts b/src/schemas/content.ts index 1ef5474..b4f9c48 100644 --- a/src/schemas/content.ts +++ b/src/schemas/content.ts @@ -159,11 +159,15 @@ export const exitTemplateSchema = z.object({ export const roomTemplateSchema = z.object({ id: z.string().min(1), level: z.number().int().positive(), + sourcePage: z.number().int().positive().optional(), roomClass: z.enum(["normal", "small", "large", "special", "start", "stairs"]), tableCode: z.string().min(1), tableEntryKey: z.string().min(1), title: z.string().min(1), text: z.string().optional(), + encounterText: z.string().optional(), + exitHint: z.string().optional(), + unique: z.boolean().optional(), dimensions: z .object({ width: z.number().int().positive(), diff --git a/src/types/content.ts b/src/types/content.ts index 4ef1ecc..a8d715c 100644 --- a/src/types/content.ts +++ b/src/types/content.ts @@ -165,11 +165,15 @@ export type RoomClass = "normal" | "small" | "large" | "special" | "start" | "st export type RoomTemplate = { id: string; level: number; + sourcePage?: number; roomClass: RoomClass; tableCode: string; tableEntryKey: string; title: string; text?: string; + encounterText?: string; + exitHint?: string; + unique?: boolean; dimensions?: { width: number; height: number; From 1ff20723ec926f24a8e49bbc336a428b2961ae88 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 13:05:48 -0500 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8Feature:=20implement=20dungeon=20s?= =?UTF-8?q?tate=20management=20with=20room=20expansion=20and=20exit=20hand?= =?UTF-8?q?ling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rules/dungeon.test.ts | 87 ++++++++++++++++ src/rules/dungeon.ts | 211 ++++++++++++++++++++++++++++++++++++++ src/rules/rooms.ts | 4 +- 3 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 src/rules/dungeon.test.ts create mode 100644 src/rules/dungeon.ts diff --git a/src/rules/dungeon.test.ts b/src/rules/dungeon.test.ts new file mode 100644 index 0000000..78cc0b8 --- /dev/null +++ b/src/rules/dungeon.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { + expandLevelFromExit, + getUnresolvedExits, + initializeDungeonLevel, +} from "./dungeon"; + +function createSequenceRoller(values: number[]) { + let index = 0; + + return () => { + const next = values[index]; + index += 1; + return next; + }; +} + +describe("dungeon state", () => { + it("initializes level 1 with a generated start room", () => { + const levelState = initializeDungeonLevel({ content: sampleContentPack }); + + expect(levelState.level).toBe(1); + expect(levelState.discoveredRoomOrder).toEqual(["room.level1.start"]); + expect(levelState.rooms["room.level1.start"]?.roomClass).toBe("start"); + }); + + it("lists unresolved traversable exits in discovery order", () => { + const levelState = initializeDungeonLevel({ content: sampleContentPack }); + + expect(getUnresolvedExits(levelState)).toEqual([ + expect.objectContaining({ + roomId: "room.level1.start", + direction: "north", + }), + ]); + }); + + it("expands the dungeon from a chosen exit and links both rooms", () => { + const levelState = initializeDungeonLevel({ content: sampleContentPack }); + const result = expandLevelFromExit({ + content: sampleContentPack, + levelState, + fromRoomId: "room.level1.start", + exitDirection: "north", + roomTableCode: "L1SR", + roller: createSequenceRoller([3, 3]), + }); + + expect(result.createdRoom.position).toEqual({ x: 0, y: -1 }); + expect(result.levelState.discoveredRoomOrder).toEqual([ + "room.level1.start", + "room.level1.small.002", + ]); + expect(result.fromRoom.exits[0]?.leadsToRoomId).toBe("room.level1.small.002"); + expect(result.createdRoom.exits.some((exit) => exit.leadsToRoomId === "room.level1.start")).toBe( + true, + ); + }); + + it("rejects placement into an occupied coordinate", () => { + const levelState = initializeDungeonLevel({ content: sampleContentPack }); + const expanded = expandLevelFromExit({ + content: sampleContentPack, + levelState, + fromRoomId: "room.level1.start", + exitDirection: "north", + roomTableCode: "L1SR", + roomId: "room.level1.small.a", + roller: createSequenceRoller([3, 3]), + }); + + expect(() => + expandLevelFromExit({ + content: sampleContentPack, + levelState: expanded.levelState, + fromRoomId: "room.level1.start", + exitDirection: "north", + roomTableCode: "L1SR", + roomId: "room.level1.small.b", + roller: createSequenceRoller([4, 4]), + }), + ).toThrow("already connected"); + }); +}); diff --git a/src/rules/dungeon.ts b/src/rules/dungeon.ts new file mode 100644 index 0000000..a5e6741 --- /dev/null +++ b/src/rules/dungeon.ts @@ -0,0 +1,211 @@ +import type { ContentPack } from "@/types/content"; +import type { DungeonLevelState, RoomExitState, RoomState } from "@/types/state"; + +import { createLevelShell, generateLevel1StartRoom, generateRoomFromTable } from "./rooms"; +import type { DiceRoller } from "./dice"; + +type CardinalDirection = "north" | "east" | "south" | "west"; + +export type InitializeLevelOptions = { + content: ContentPack; + level?: number; + startRoomId?: string; +}; + +export type ExpandFromExitOptions = { + content: ContentPack; + levelState: DungeonLevelState; + fromRoomId: string; + exitDirection: CardinalDirection; + roomTableCode: string; + roomId?: string; + roller?: DiceRoller; +}; + +export type ExpansionResult = { + levelState: DungeonLevelState; + createdRoom: RoomState; + fromRoom: RoomState; +}; + +const DIRECTION_VECTORS: Record = { + north: { x: 0, y: -1 }, + east: { x: 1, y: 0 }, + south: { x: 0, y: 1 }, + west: { x: -1, y: 0 }, +}; + +const OPPOSITE_DIRECTION: Record = { + north: "south", + east: "west", + south: "north", + west: "east", +}; + +function cloneRoom(room: RoomState): RoomState { + return { + ...room, + position: { ...room.position }, + dimensions: { ...room.dimensions }, + exits: room.exits.map((exit) => ({ ...exit })), + discovery: { ...room.discovery }, + encounter: room.encounter ? { ...room.encounter, creatureIds: [...room.encounter.creatureIds] } : undefined, + objects: room.objects.map((object) => ({ + ...object, + effects: object.effects ? [...object.effects] : undefined, + })), + notes: [...room.notes], + flags: [...room.flags], + }; +} + +function cloneLevel(levelState: DungeonLevelState): DungeonLevelState { + return { + ...levelState, + rooms: Object.fromEntries( + Object.entries(levelState.rooms).map(([roomId, room]) => [roomId, cloneRoom(room)]), + ), + discoveredRoomOrder: [...levelState.discoveredRoomOrder], + }; +} + +function findExit(room: RoomState, direction: CardinalDirection): RoomExitState { + const exit = room.exits.find((candidate) => candidate.direction === direction); + + if (!exit) { + throw new Error(`Room ${room.id} does not have an exit facing ${direction}.`); + } + + return exit; +} + +function computeNextPosition(room: RoomState, direction: CardinalDirection) { + const offset = DIRECTION_VECTORS[direction]; + + return { + x: room.position.x + offset.x, + y: room.position.y + offset.y, + }; +} + +function connectRooms( + fromRoom: RoomState, + toRoom: RoomState, + direction: CardinalDirection, +) { + const fromExit = findExit(fromRoom, direction); + fromExit.leadsToRoomId = toRoom.id; + fromExit.discovered = true; + + const oppositeDirection = OPPOSITE_DIRECTION[direction]; + const returnExit = + toRoom.exits.find((candidate) => candidate.direction === oppositeDirection) ?? toRoom.exits[0]; + + if (!returnExit) { + throw new Error(`Generated room ${toRoom.id} does not have an available return exit.`); + } + + returnExit.direction = oppositeDirection; + returnExit.leadsToRoomId = fromRoom.id; + returnExit.discovered = true; +} + +function getNextRoomId(levelState: DungeonLevelState, roomClass: string) { + const sequence = levelState.discoveredRoomOrder.length + 1; + return `room.level${levelState.level}.${roomClass}.${String(sequence).padStart(3, "0")}`; +} + +function assertCoordinateAvailable(levelState: DungeonLevelState, position: { x: number; y: number }) { + const occupiedRoom = Object.values(levelState.rooms).find( + (room) => room.position.x === position.x && room.position.y === position.y, + ); + + if (occupiedRoom) { + throw new Error( + `Cannot place a new room at (${position.x}, ${position.y}); ${occupiedRoom.id} already occupies that position.`, + ); + } +} + +export function initializeDungeonLevel( + options: InitializeLevelOptions, +): DungeonLevelState { + const level = options.level ?? 1; + + if (level !== 1) { + throw new Error("Only Level 1 initialization is currently implemented."); + } + + const levelState = createLevelShell(level); + const startResult = generateLevel1StartRoom( + options.content, + options.startRoomId ?? `room.level${level}.start`, + ); + + levelState.rooms[startResult.room.id] = startResult.room; + levelState.discoveredRoomOrder.push(startResult.room.id); + + return levelState; +} + +export function getUnresolvedExits(levelState: DungeonLevelState) { + return levelState.discoveredRoomOrder.flatMap((roomId) => { + const room = levelState.rooms[roomId]; + + return room.exits + .filter((exit) => !exit.leadsToRoomId && exit.traversable) + .map((exit) => ({ + roomId, + exitId: exit.id, + direction: exit.direction, + exitType: exit.exitType, + })); + }); +} + +export function expandLevelFromExit( + options: ExpandFromExitOptions, +): ExpansionResult { + const levelState = cloneLevel(options.levelState); + const fromRoom = levelState.rooms[options.fromRoomId]; + + if (!fromRoom) { + throw new Error(`Unknown source room id: ${options.fromRoomId}`); + } + + const sourceExit = findExit(fromRoom, options.exitDirection); + + if (sourceExit.leadsToRoomId) { + throw new Error(`Exit ${sourceExit.id} is already connected to ${sourceExit.leadsToRoomId}.`); + } + + if (!sourceExit.traversable) { + throw new Error(`Exit ${sourceExit.id} is not traversable.`); + } + + const position = computeNextPosition(fromRoom, options.exitDirection); + assertCoordinateAvailable(levelState, position); + + const provisionalRoomId = + options.roomId ?? getNextRoomId(levelState, options.roomTableCode === "L1SR" ? "small" : "room"); + const result = generateRoomFromTable({ + content: options.content, + roomId: provisionalRoomId, + level: levelState.level, + roomTableCode: options.roomTableCode, + position, + roller: options.roller, + }); + + connectRooms(fromRoom, result.room, options.exitDirection); + + levelState.rooms[fromRoom.id] = fromRoom; + levelState.rooms[result.room.id] = result.room; + levelState.discoveredRoomOrder.push(result.room.id); + + return { + levelState, + createdRoom: result.room, + fromRoom, + }; +} diff --git a/src/rules/rooms.ts b/src/rules/rooms.ts index ed65474..d581be7 100644 --- a/src/rules/rooms.ts +++ b/src/rules/rooms.ts @@ -4,7 +4,7 @@ import { findTableByCode, } from "@/data/contentHelpers"; import type { ContentPack, ExitType, RoomClass } from "@/types/content"; -import type { RoomExitState, RoomState } from "@/types/state"; +import type { DungeonLevelState, RoomExitState, RoomState } from "@/types/state"; import { lookupTable, type TableLookupResult } from "./tables"; import type { DiceRoller } from "./dice"; @@ -186,7 +186,7 @@ export function generateLevel1StartRoom( }; } -export function createLevelShell(level: number) { +export function createLevelShell(level: number): DungeonLevelState { return { level, themeName: level === 1 ? "The Entry" : undefined, From 39703ce6b05ddb206f67b3ad771e7b510759ffa2 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 13:11:28 -0500 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8Feature:=20enhance=20encounter=20r?= =?UTF-8?q?esolution=20with=20creature=20names=20and=20result=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rules/dungeon.ts | 10 +++- src/rules/encounters.test.ts | 73 +++++++++++++++++++++++ src/rules/encounters.ts | 110 +++++++++++++++++++++++++++++++++++ src/schemas/state.ts | 2 + src/types/state.ts | 2 + 5 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 src/rules/encounters.test.ts create mode 100644 src/rules/encounters.ts diff --git a/src/rules/dungeon.ts b/src/rules/dungeon.ts index a5e6741..adb7b41 100644 --- a/src/rules/dungeon.ts +++ b/src/rules/dungeon.ts @@ -49,7 +49,15 @@ function cloneRoom(room: RoomState): RoomState { dimensions: { ...room.dimensions }, exits: room.exits.map((exit) => ({ ...exit })), discovery: { ...room.discovery }, - encounter: room.encounter ? { ...room.encounter, creatureIds: [...room.encounter.creatureIds] } : undefined, + encounter: room.encounter + ? { + ...room.encounter, + creatureIds: [...room.encounter.creatureIds], + creatureNames: room.encounter.creatureNames + ? [...room.encounter.creatureNames] + : undefined, + } + : undefined, objects: room.objects.map((object) => ({ ...object, effects: object.effects ? [...object.effects] : undefined, diff --git a/src/rules/encounters.test.ts b/src/rules/encounters.test.ts new file mode 100644 index 0000000..054259b --- /dev/null +++ b/src/rules/encounters.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { createRoomStateFromTemplate } from "./rooms"; +import { resolveRoomEncounter } from "./encounters"; + +function createSequenceRoller(values: number[]) { + let index = 0; + + return () => { + const next = values[index]; + index += 1; + return next; + }; +} + +describe("encounter resolution", () => { + it("marks explicitly empty rooms as having no encounter", () => { + const room = createRoomStateFromTemplate( + sampleContentPack, + "room.level1.small.empty", + 1, + "room.level1.small.empty-space", + ); + room.notes.push("Nothing here."); + + const result = resolveRoomEncounter(sampleContentPack, room); + + expect(result.encounter?.creatureIds).toEqual([]); + expect(result.encounter?.resultLabel).toBe("No encounter"); + }); + + it("uses room context to resolve a guard encounter table", () => { + const room = createRoomStateFromTemplate( + sampleContentPack, + "room.level1.guard-room", + 1, + "room.level1.normal.guard-room", + ); + room.notes.push("Use the guards encounter table if occupied."); + + const result = resolveRoomEncounter( + sampleContentPack, + room, + createSequenceRoller([6]), + ); + + expect(result.lookup?.entry.label).toBe("Guard and Warrior"); + expect(result.encounter?.sourceTableCode).toBe("L1G"); + expect(result.encounter?.creatureNames).toEqual(["Guard", "Warrior"]); + }); + + it("resolves crate events from room context when the room hints at crate rolls", () => { + const room = createRoomStateFromTemplate( + sampleContentPack, + "room.level1.storage-area", + 1, + "room.level1.normal.storage-area", + ); + room.notes.push("Roll for food, drink, or crate events while searching."); + + const result = resolveRoomEncounter( + sampleContentPack, + room, + createSequenceRoller([2]), + ); + + expect(result.lookup?.entry.label).toBe("Giant Rat Pair"); + expect(result.encounter?.sourceTableCode).toBe("L1CE"); + expect(result.encounter?.creatureNames).toEqual(["Giant Rat Pair"]); + }); +}); diff --git a/src/rules/encounters.ts b/src/rules/encounters.ts new file mode 100644 index 0000000..d64d038 --- /dev/null +++ b/src/rules/encounters.ts @@ -0,0 +1,110 @@ +import { findTableByCode } from "@/data/contentHelpers"; +import type { ContentPack } from "@/types/content"; +import type { EncounterState, RoomState } from "@/types/state"; + +import { lookupTable, type TableLookupResult } from "./tables"; +import type { DiceRoller } from "./dice"; + +export type EncounterResolutionResult = { + room: RoomState; + encounter?: EncounterState; + lookup?: TableLookupResult; +}; + +const ROOM_HINT_TO_TABLE: Array<{ pattern: RegExp; tableCode: string }> = [ + { pattern: /\bdogs?\b/i, tableCode: "L1D" }, + { pattern: /\bguards?\b/i, tableCode: "L1G" }, + { pattern: /\bmartial\b/i, tableCode: "L1M" }, + { pattern: /\bworkers?\b/i, tableCode: "L1W" }, + { pattern: /\blabourers?\b/i, tableCode: "L1P" }, + { pattern: /\bpreacher\b/i, tableCode: "L1P" }, + { pattern: /\banimals?\b/i, tableCode: "L1A" }, + { pattern: /\bsnakes?\b/i, tableCode: "L1A" }, + { pattern: /\bfungal\b/i, tableCode: "L1F" }, + { pattern: /\bslimy\b/i, tableCode: "L1F" }, + { pattern: /\bcrate\b/i, tableCode: "L1CE" }, +]; + +function splitEncounterLabel(label: string) { + return label + .split(/\sand\s/i) + .map((part) => part.trim()) + .filter(Boolean); +} + +function inferEncounterTableCode(room: RoomState) { + const noteText = room.notes.join(" "); + const flagText = room.flags.join(" "); + const combinedText = `${noteText} ${flagText}`; + + for (const mapping of ROOM_HINT_TO_TABLE) { + if (mapping.pattern.test(combinedText)) { + return mapping.tableCode; + } + } + + return undefined; +} + +function isNonEncounterRoom(room: RoomState) { + const noteText = room.notes.join(" ").toLowerCase(); + return noteText.includes("nothing here") || noteText.includes("no encounter"); +} + +export function resolveRoomEncounter( + content: ContentPack, + room: RoomState, + roller?: DiceRoller, +): EncounterResolutionResult { + if (room.encounter?.resolved) { + return { + room, + encounter: room.encounter, + }; + } + + if (isNonEncounterRoom(room)) { + const emptyEncounter: EncounterState = { + id: `${room.id}.encounter`, + creatureIds: [], + creatureNames: [], + resultLabel: "No encounter", + resolved: true, + }; + + return { + room: { + ...room, + encounter: emptyEncounter, + }, + encounter: emptyEncounter, + }; + } + + const tableCode = inferEncounterTableCode(room); + + if (!tableCode) { + return { room }; + } + + const table = findTableByCode(content, tableCode); + const lookup = lookupTable(table, { roller }); + const creatureNames = splitEncounterLabel(lookup.entry.label); + const encounter: EncounterState = { + id: `${room.id}.encounter`, + sourceTableCode: tableCode, + creatureIds: creatureNames.map((_, index) => `${room.id}.creature.${index + 1}`), + creatureNames, + resultLabel: lookup.entry.label, + resolved: true, + }; + + return { + room: { + ...room, + encounter, + }, + encounter, + lookup, + }; +} diff --git a/src/schemas/state.ts b/src/schemas/state.ts index 6b351cb..f844ad9 100644 --- a/src/schemas/state.ts +++ b/src/schemas/state.ts @@ -110,6 +110,8 @@ export const encounterStateSchema = z.object({ id: z.string().min(1), sourceTableCode: z.string().optional(), creatureIds: z.array(z.string()), + creatureNames: z.array(z.string()).optional(), + resultLabel: z.string().optional(), resolved: z.boolean(), surprise: z.boolean().optional(), rewardPending: z.boolean().optional(), diff --git a/src/types/state.ts b/src/types/state.ts index d98200d..2933a89 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -111,6 +111,8 @@ export type EncounterState = { id: string; sourceTableCode?: string; creatureIds: string[]; + creatureNames?: string[]; + resultLabel?: string; resolved: boolean; surprise?: boolean; rewardPending?: boolean; From 4dde4bff9900bedd952bd08f1fce913d26ee1c0a Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 13:26:59 -0500 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=A8Feature:=20implement=20room=20entr?= =?UTF-8?q?y=20flow=20with=20encounter=20resolution=20and=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rules/roomEntry.test.ts | 115 +++++++++++++++++++++++++ src/rules/roomEntry.ts | 164 ++++++++++++++++++++++++++++++++++++ src/rules/rooms.ts | 4 +- 3 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 src/rules/roomEntry.test.ts create mode 100644 src/rules/roomEntry.ts diff --git a/src/rules/roomEntry.test.ts b/src/rules/roomEntry.test.ts new file mode 100644 index 0000000..a113ae7 --- /dev/null +++ b/src/rules/roomEntry.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { expandLevelFromExit, initializeDungeonLevel } from "./dungeon"; +import { createRoomStateFromTemplate } from "./rooms"; +import { enterRoom } from "./roomEntry"; + +function createSequenceRoller(values: number[]) { + let index = 0; + + return () => { + const next = values[index]; + index += 1; + return next; + }; +} + +describe("room entry flow", () => { + it("marks a newly generated room as entered and resolves an empty encounter", () => { + const levelState = initializeDungeonLevel({ content: sampleContentPack }); + const expanded = expandLevelFromExit({ + content: sampleContentPack, + levelState, + fromRoomId: "room.level1.start", + exitDirection: "north", + roomTableCode: "L1SR", + roller: createSequenceRoller([1, 1]), + }); + + const result = enterRoom({ + content: sampleContentPack, + levelState: expanded.levelState, + roomId: expanded.createdRoom.id, + at: "2026-03-15T12:00:00.000Z", + }); + + expect(result.firstEntry).toBe(true); + expect(result.encounterResolved).toBe(true); + expect(result.room.discovery.entered).toBe(true); + expect(result.room.discovery.cleared).toBe(true); + expect(result.room.encounter?.resultLabel).toBe("No encounter"); + expect(result.logEntries.map((entry) => entry.text)).toEqual([ + "Entered Empty Space.", + "Empty Space is quiet.", + ]); + }); + + it("resolves a guard encounter once and records the lookup roll", () => { + const levelState = initializeDungeonLevel({ content: sampleContentPack }); + const guardRoom = createRoomStateFromTemplate( + sampleContentPack, + "room.level1.guard-room", + 1, + "room.level1.normal.guard-room", + { x: 0, y: -1 }, + ); + + levelState.rooms[guardRoom.id] = guardRoom; + levelState.discoveredRoomOrder.push(guardRoom.id); + + const result = enterRoom({ + content: sampleContentPack, + levelState, + roomId: guardRoom.id, + roller: createSequenceRoller([6]), + at: "2026-03-15T12:00:00.000Z", + }); + + expect(result.room.templateId).toBe("room.level1.normal.guard-room"); + expect(result.lookup?.entry.label).toBe("Guard and Warrior"); + expect(result.room.encounter?.creatureNames).toEqual(["Guard", "Warrior"]); + expect(result.room.discovery.cleared).toBe(false); + expect(result.logEntries.map((entry) => entry.type)).toEqual(["room", "roll", "room"]); + expect(result.logEntries[1]?.text).toContain("Guard and Warrior"); + }); + + it("does not reroll an encounter when re-entering a room", () => { + const levelState = initializeDungeonLevel({ content: sampleContentPack }); + const guardRoom = createRoomStateFromTemplate( + sampleContentPack, + "room.level1.guard-room", + 1, + "room.level1.normal.guard-room", + { x: 0, y: -1 }, + ); + + levelState.rooms[guardRoom.id] = guardRoom; + levelState.discoveredRoomOrder.push(guardRoom.id); + + const firstEntry = enterRoom({ + content: sampleContentPack, + levelState, + roomId: guardRoom.id, + roller: createSequenceRoller([4]), + at: "2026-03-15T12:00:00.000Z", + }); + const secondEntry = enterRoom({ + content: sampleContentPack, + levelState: firstEntry.levelState, + roomId: guardRoom.id, + roller: createSequenceRoller([6]), + at: "2026-03-15T12:05:00.000Z", + }); + + expect(firstEntry.room.encounter?.resultLabel).toBe("Guard"); + expect(secondEntry.room.encounter?.resultLabel).toBe("Guard"); + expect(secondEntry.lookup).toBeUndefined(); + expect(secondEntry.firstEntry).toBe(false); + expect(secondEntry.logEntries.map((entry) => entry.text)).toEqual([ + "Re-entered Guard Room.", + "Encounter in Guard Room: Guard.", + ]); + }); +}); diff --git a/src/rules/roomEntry.ts b/src/rules/roomEntry.ts new file mode 100644 index 0000000..e649d98 --- /dev/null +++ b/src/rules/roomEntry.ts @@ -0,0 +1,164 @@ +import { findRoomTemplateById } from "@/data/contentHelpers"; +import type { ContentPack } from "@/types/content"; +import type { DungeonLevelState, RoomState } from "@/types/state"; +import type { LogEntry } from "@/types/rules"; + +import type { DiceRoller } from "./dice"; +import { resolveRoomEncounter } from "./encounters"; +import type { TableLookupResult } from "./tables"; + +export type EnterRoomOptions = { + content: ContentPack; + levelState: DungeonLevelState; + roomId: string; + roller?: DiceRoller; + at?: string; +}; + +export type EnterRoomResult = { + levelState: DungeonLevelState; + room: RoomState; + logEntries: LogEntry[]; + lookup?: TableLookupResult; + firstEntry: boolean; + encounterResolved: boolean; +}; + +function cloneRoom(room: RoomState): RoomState { + return { + ...room, + position: { ...room.position }, + dimensions: { ...room.dimensions }, + exits: room.exits.map((exit) => ({ ...exit })), + discovery: { ...room.discovery }, + encounter: room.encounter + ? { + ...room.encounter, + creatureIds: [...room.encounter.creatureIds], + creatureNames: room.encounter.creatureNames + ? [...room.encounter.creatureNames] + : undefined, + } + : undefined, + objects: room.objects.map((object) => ({ + ...object, + effects: object.effects ? [...object.effects] : undefined, + })), + notes: [...room.notes], + flags: [...room.flags], + }; +} + +function cloneLevel(levelState: DungeonLevelState): DungeonLevelState { + return { + ...levelState, + rooms: Object.fromEntries( + Object.entries(levelState.rooms).map(([roomId, room]) => [roomId, cloneRoom(room)]), + ), + discoveredRoomOrder: [...levelState.discoveredRoomOrder], + }; +} + +function getRoomLabel(content: ContentPack, room: RoomState) { + if (room.templateId) { + return findRoomTemplateById(content, room.templateId).title; + } + + return room.notes[0] ?? room.id; +} + +function createLogEntry( + id: string, + at: string, + type: LogEntry["type"], + text: string, + relatedIds: string[], +): LogEntry { + return { + id, + at, + type, + text, + relatedIds, + }; +} + +function formatRollText(lookup: TableLookupResult) { + const total = lookup.roll.modifiedTotal ?? lookup.roll.total; + const rolledValues = lookup.roll.rolls.join(", "); + + return `Rolled ${lookup.roll.diceKind} [${rolledValues}] on ${lookup.tableId} for ${total}: ${lookup.entry.label}.`; +} + +export function enterRoom(options: EnterRoomOptions): EnterRoomResult { + const levelState = cloneLevel(options.levelState); + const room = levelState.rooms[options.roomId]; + + if (!room) { + throw new Error(`Unknown room id: ${options.roomId}`); + } + + const at = options.at ?? new Date().toISOString(); + const roomLabel = getRoomLabel(options.content, room); + const firstEntry = !room.discovery.entered; + room.discovery.entered = true; + + const resolution = resolveRoomEncounter(options.content, room, options.roller); + const nextRoom = resolution.room; + const encounterResolved = Boolean(resolution.encounter); + + if (resolution.encounter && resolution.encounter.creatureIds.length === 0) { + nextRoom.discovery.cleared = true; + } + + levelState.rooms[nextRoom.id] = nextRoom; + + const entryVerb = firstEntry ? "Entered" : "Re-entered"; + const logEntries: LogEntry[] = [ + createLogEntry( + `${nextRoom.id}.entry.${firstEntry ? "first" : "repeat"}`, + at, + "room", + `${entryVerb} ${roomLabel}.`, + [nextRoom.id], + ), + ]; + + if (resolution.lookup) { + logEntries.push( + createLogEntry( + `${nextRoom.id}.entry.roll`, + at, + "roll", + formatRollText(resolution.lookup), + [nextRoom.id, resolution.lookup.tableId], + ), + ); + } + + if (resolution.encounter) { + const encounterText = + resolution.encounter.creatureIds.length === 0 + ? `${roomLabel} is quiet.` + : `Encounter in ${roomLabel}: ${resolution.encounter.resultLabel ?? "Unknown threat"}.`; + + logEntries.push( + createLogEntry( + `${nextRoom.id}.entry.encounter`, + at, + "room", + encounterText, + [nextRoom.id, resolution.encounter.id], + ), + ); + } + + return { + levelState, + room: nextRoom, + logEntries, + lookup: resolution.lookup, + firstEntry, + encounterResolved, + }; +} diff --git a/src/rules/rooms.ts b/src/rules/rooms.ts index d581be7..7b0228c 100644 --- a/src/rules/rooms.ts +++ b/src/rules/rooms.ts @@ -136,7 +136,9 @@ export function createRoomStateFromTemplate( }, encounter: undefined, objects: [], - notes: [template.text ?? template.title].filter(Boolean), + notes: [template.text ?? template.title, template.encounterText].filter( + (note): note is string => Boolean(note), + ), flags: [ `table:${template.tableCode}`, `entry:${template.tableEntryKey}`, From ec0de5b0b8459b73d8a9eb4cf316b01ce93e850b Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 13:35:13 -0500 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=A8Feature:=20add=20creature=20lookup?= =?UTF-8?q?=20functionality=20and=20combat=20state=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/contentHelpers.test.ts | 13 +++- src/data/contentHelpers.ts | 27 ++++++- src/data/sampleContentPack.ts | 86 ++++++++++++++++++++++ src/rules/combat.test.ts | 111 ++++++++++++++++++++++++++++ src/rules/combat.ts | 124 ++++++++++++++++++++++++++++++++ 5 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 src/rules/combat.test.ts create mode 100644 src/rules/combat.ts diff --git a/src/data/contentHelpers.test.ts b/src/data/contentHelpers.test.ts index 41c498d..ccc4880 100644 --- a/src/data/contentHelpers.test.ts +++ b/src/data/contentHelpers.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import { lookupTable } from "@/rules/tables"; -import { findRoomTemplateForLookup, findTableByCode } from "./contentHelpers"; +import { + findCreatureByName, + findRoomTemplateForLookup, + findTableByCode, +} from "./contentHelpers"; import { sampleContentPack } from "./sampleContentPack"; function createSequenceRoller(values: number[]) { @@ -46,4 +50,11 @@ describe("level 1 content helpers", () => { expect(roomTemplate.title).toBe("Slate Shrine"); expect(roomTemplate.roomClass).toBe("large"); }); + + it("finds a creature definition by display name", () => { + const creature = findCreatureByName(sampleContentPack, "Guard"); + + expect(creature.id).toBe("creature.level1.guard"); + expect(creature.hp).toBeGreaterThan(0); + }); }); diff --git a/src/data/contentHelpers.ts b/src/data/contentHelpers.ts index dec59d9..c4f4dfe 100644 --- a/src/data/contentHelpers.ts +++ b/src/data/contentHelpers.ts @@ -1,4 +1,9 @@ -import type { ContentPack, RoomTemplate, TableDefinition } from "@/types/content"; +import type { + ContentPack, + CreatureDefinition, + RoomTemplate, + TableDefinition, +} from "@/types/content"; import type { TableLookupResult } from "@/rules/tables"; export function findTableByCode(content: ContentPack, code: string): TableDefinition { @@ -36,3 +41,23 @@ export function findRoomTemplateForLookup( return findRoomTemplateById(content, roomReference.id); } + +function normalizeName(value: string) { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +export function findCreatureByName( + content: ContentPack, + creatureName: string, +): CreatureDefinition { + const normalizedTarget = normalizeName(creatureName); + const creature = content.creatures.find( + (entry) => normalizeName(entry.name) === normalizedTarget, + ); + + if (!creature) { + throw new Error(`Unknown creature name: ${creatureName}`); + } + + return creature; +} diff --git a/src/data/sampleContentPack.ts b/src/data/sampleContentPack.ts index 06e3393..b2cd348 100644 --- a/src/data/sampleContentPack.ts +++ b/src/data/sampleContentPack.ts @@ -167,6 +167,92 @@ const samplePack = { traits: ["level-1", "sample"], mvp: true, }, + { + id: "creature.level1.guard", + name: "Guard", + level: 1, + category: "humanoid", + hp: 4, + attackProfile: { + discipline: 1, + precision: 1, + damage: 1, + }, + defenceProfile: { + armour: 1, + }, + xpReward: 2, + sourcePage: 102, + traits: ["level-1", "martial"], + mvp: true, + }, + { + id: "creature.level1.warrior", + name: "Warrior", + level: 1, + category: "humanoid", + hp: 5, + attackProfile: { + discipline: 2, + precision: 1, + damage: 2, + }, + defenceProfile: { + armour: 1, + }, + xpReward: 3, + sourcePage: 102, + traits: ["level-1", "martial"], + mvp: true, + }, + { + id: "creature.level1.thug", + name: "Thug", + level: 1, + category: "humanoid", + hp: 3, + attackProfile: { + discipline: 0, + precision: 1, + damage: 1, + }, + xpReward: 1, + sourcePage: 102, + traits: ["level-1", "martial"], + mvp: true, + }, + { + id: "creature.level1.work-dog", + name: "Work Dog", + level: 1, + category: "beast", + hp: 3, + attackProfile: { + discipline: 0, + precision: 1, + damage: 1, + }, + xpReward: 1, + sourcePage: 102, + traits: ["level-1", "beast"], + mvp: true, + }, + { + id: "creature.level1.guard-dog", + name: "Guard Dog", + level: 1, + category: "beast", + hp: 4, + attackProfile: { + discipline: 1, + precision: 1, + damage: 1, + }, + xpReward: 2, + sourcePage: 102, + traits: ["level-1", "beast"], + mvp: true, + }, ], roomTemplates: [ { diff --git a/src/rules/combat.test.ts b/src/rules/combat.test.ts new file mode 100644 index 0000000..dc2ba88 --- /dev/null +++ b/src/rules/combat.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { createStartingAdventurer } from "./character"; +import { startCombatFromRoom } from "./combat"; +import { createRoomStateFromTemplate } from "./rooms"; + +describe("combat bootstrap", () => { + it("creates a combat state from a resolved guard encounter", () => { + const adventurer = createStartingAdventurer(sampleContentPack, { + name: "Aster", + weaponId: "weapon.short-sword", + armourId: "armour.leather-vest", + scrollId: "scroll.lesser-heal", + }); + const room = createRoomStateFromTemplate( + sampleContentPack, + "room.level1.guard-room", + 1, + "room.level1.normal.guard-room", + ); + room.encounter = { + id: `${room.id}.encounter`, + sourceTableCode: "L1G", + creatureIds: ["a", "b"], + creatureNames: ["Guard", "Warrior"], + resultLabel: "Guard and Warrior", + resolved: true, + }; + + const result = startCombatFromRoom({ + content: sampleContentPack, + adventurer, + room, + at: "2026-03-15T13:00:00.000Z", + }); + + expect(result.combat.id).toBe("room.level1.guard-room.combat"); + expect(result.combat.actingSide).toBe("player"); + expect(result.combat.player.name).toBe("Aster"); + expect(result.combat.enemies.map((enemy) => enemy.name)).toEqual(["Guard", "Warrior"]); + expect(result.combat.enemies[0]?.armourValue).toBe(1); + expect(result.room.encounter?.rewardPending).toBe(true); + expect(result.logEntries[0]?.text).toContain("Guard and Warrior"); + }); + + it("supports single-creature combat from a crate encounter", () => { + const adventurer = createStartingAdventurer(sampleContentPack, { + name: "Bryn", + weaponId: "weapon.short-sword", + armourId: "armour.leather-vest", + scrollId: "scroll.lesser-heal", + }); + const room = createRoomStateFromTemplate( + sampleContentPack, + "room.level1.storage-area", + 1, + "room.level1.normal.storage-area", + ); + room.encounter = { + id: `${room.id}.encounter`, + sourceTableCode: "L1CE", + creatureIds: ["a"], + creatureNames: ["Giant Rat"], + resultLabel: "Giant Rat Pair", + resolved: true, + }; + + const result = startCombatFromRoom({ + content: sampleContentPack, + adventurer, + room, + at: "2026-03-15T13:05:00.000Z", + }); + + expect(result.combat.enemies).toHaveLength(1); + expect(result.combat.enemies[0]?.sourceDefinitionId).toBe("creature.level1.giant-rat"); + expect(result.combat.player.hpCurrent).toBe(10); + }); + + it("rejects rooms without combat-ready encounter names", () => { + const adventurer = createStartingAdventurer(sampleContentPack, { + name: "Cyra", + weaponId: "weapon.short-sword", + armourId: "armour.leather-vest", + scrollId: "scroll.lesser-heal", + }); + const room = createRoomStateFromTemplate( + sampleContentPack, + "room.level1.small.empty", + 1, + "room.level1.small.empty-space", + ); + room.encounter = { + id: `${room.id}.encounter`, + creatureIds: [], + creatureNames: [], + resultLabel: "No encounter", + resolved: true, + }; + + expect(() => + startCombatFromRoom({ + content: sampleContentPack, + adventurer, + room, + }), + ).toThrow("combat-ready encounter"); + }); +}); diff --git a/src/rules/combat.ts b/src/rules/combat.ts new file mode 100644 index 0000000..458ab3f --- /dev/null +++ b/src/rules/combat.ts @@ -0,0 +1,124 @@ +import { findCreatureByName } from "@/data/contentHelpers"; +import type { ContentPack, CreatureDefinition } from "@/types/content"; +import type { AdventurerState, CombatState, CombatantState, RoomState } from "@/types/state"; +import type { LogEntry } from "@/types/rules"; + +export type StartCombatOptions = { + content: ContentPack; + adventurer: AdventurerState; + room: RoomState; + at?: string; +}; + +export type StartCombatResult = { + combat: CombatState; + room: RoomState; + logEntries: LogEntry[]; +}; + +function createLogEntry( + id: string, + at: string, + type: LogEntry["type"], + text: string, + relatedIds: string[], +): LogEntry { + return { + id, + at, + type, + text, + relatedIds, + }; +} + +function createPlayerCombatant(adventurer: AdventurerState): CombatantState { + return { + id: adventurer.id, + name: adventurer.name, + hpCurrent: adventurer.hp.current, + hpMax: adventurer.hp.max, + shift: adventurer.stats.shift, + discipline: adventurer.stats.discipline, + precision: adventurer.stats.precision, + statuses: [...adventurer.statuses], + traits: ["player"], + }; +} + +function createEnemyCombatant( + room: RoomState, + creature: CreatureDefinition, + index: number, +): CombatantState { + return { + id: `${room.id}.enemy.${index + 1}`, + name: creature.name, + sourceDefinitionId: creature.id, + hpCurrent: creature.hp, + hpMax: creature.hp, + shift: 0, + discipline: creature.attackProfile.discipline, + precision: creature.attackProfile.precision, + armourValue: creature.defenceProfile?.armour, + statuses: [], + traits: creature.traits ?? [], + }; +} + +function requireEncounterNames(room: RoomState) { + const creatureNames = room.encounter?.creatureNames?.filter(Boolean); + + if (!room.encounter?.resolved || !creatureNames || creatureNames.length === 0) { + throw new Error(`Room ${room.id} does not have a combat-ready encounter.`); + } + + return creatureNames; +} + +export function startCombatFromRoom( + options: StartCombatOptions, +): StartCombatResult { + const at = options.at ?? new Date().toISOString(); + const creatureNames = requireEncounterNames(options.room); + const enemies = creatureNames.map((creatureName, index) => + createEnemyCombatant( + options.room, + findCreatureByName(options.content, creatureName), + index, + ), + ); + + const combat: CombatState = { + id: `${options.room.id}.combat`, + round: 1, + actingSide: "player", + player: createPlayerCombatant(options.adventurer), + enemies, + combatLog: [ + createLogEntry( + `${options.room.id}.combat.start`, + at, + "combat", + `Combat begins in ${options.room.id} against ${creatureNames.join(" and ")}.`, + [options.room.id, ...enemies.map((enemy) => enemy.id)], + ), + ], + }; + + const room: RoomState = { + ...options.room, + encounter: options.room.encounter + ? { + ...options.room.encounter, + rewardPending: true, + } + : undefined, + }; + + return { + combat, + room, + logEntries: [...combat.combatLog], + }; +} From 50873e6989771b070bb7ca41f5faaf47bad5904a Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 13:44:46 -0500 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9C=A8Feature:=20implement=20combat=20tu?= =?UTF-8?q?rn=20resolution=20with=20player=20and=20enemy=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rules/combatTurns.test.ts | 132 ++++++++++++++++++ src/rules/combatTurns.ts | 255 ++++++++++++++++++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 src/rules/combatTurns.test.ts create mode 100644 src/rules/combatTurns.ts diff --git a/src/rules/combatTurns.test.ts b/src/rules/combatTurns.test.ts new file mode 100644 index 0000000..fdcb5fe --- /dev/null +++ b/src/rules/combatTurns.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { createStartingAdventurer } from "./character"; +import { startCombatFromRoom } from "./combat"; +import { resolveEnemyTurn, resolvePlayerAttack } from "./combatTurns"; +import { createRoomStateFromTemplate } from "./rooms"; + +function createSequenceRoller(values: number[]) { + let index = 0; + + return () => { + const next = values[index]; + index += 1; + return next; + }; +} + +function createGuardCombat() { + const adventurer = createStartingAdventurer(sampleContentPack, { + name: "Aster", + weaponId: "weapon.short-sword", + armourId: "armour.leather-vest", + scrollId: "scroll.lesser-heal", + }); + const room = createRoomStateFromTemplate( + sampleContentPack, + "room.level1.guard-room", + 1, + "room.level1.normal.guard-room", + ); + room.encounter = { + id: `${room.id}.encounter`, + sourceTableCode: "L1G", + creatureIds: ["a", "b"], + creatureNames: ["Guard", "Warrior"], + resultLabel: "Guard and Warrior", + resolved: true, + }; + + return { + adventurer, + ...startCombatFromRoom({ + content: sampleContentPack, + adventurer, + room, + at: "2026-03-15T13:00:00.000Z", + }), + }; +} + +describe("combat turns", () => { + it("lets the player hit an enemy and pass initiative", () => { + const { adventurer, combat } = createGuardCombat(); + const targetId = combat.enemies[0]!.id; + + const result = resolvePlayerAttack({ + content: sampleContentPack, + combat, + adventurer, + manoeuvreId: "manoeuvre.exact-strike", + targetEnemyId: targetId, + roller: createSequenceRoller([6, 6]), + at: "2026-03-15T13:01:00.000Z", + }); + + expect(result.combat.actingSide).toBe("enemy"); + expect(result.combat.selectedManoeuvreId).toBe("manoeuvre.exact-strike"); + expect(result.combat.enemies[0]?.hpCurrent).toBeLessThan(result.combat.enemies[0]!.hpMax); + expect(result.logEntries[0]?.text).toContain("deals"); + }); + + it("marks an enemy defeated when the player deals lethal damage", () => { + const { adventurer, combat } = createGuardCombat(); + combat.enemies[0]!.hpCurrent = 1; + + const result = resolvePlayerAttack({ + content: sampleContentPack, + combat, + adventurer, + manoeuvreId: "manoeuvre.guard-break", + targetEnemyId: combat.enemies[0]!.id, + roller: createSequenceRoller([6, 5]), + at: "2026-03-15T13:02:00.000Z", + }); + + expect(result.defeatedEnemyIds).toEqual([combat.enemies[0]!.id]); + expect(result.logEntries.map((entry) => entry.text)).toContain("Guard is defeated."); + }); + + it("lets an enemy attack the player and advances the round", () => { + const { adventurer, combat } = createGuardCombat(); + const afterPlayer = resolvePlayerAttack({ + content: sampleContentPack, + combat, + adventurer, + manoeuvreId: "manoeuvre.exact-strike", + targetEnemyId: combat.enemies[0]!.id, + roller: createSequenceRoller([4, 4]), + at: "2026-03-15T13:03:00.000Z", + }); + + const result = resolveEnemyTurn({ + content: sampleContentPack, + combat: afterPlayer.combat, + adventurer, + roller: createSequenceRoller([6, 6]), + at: "2026-03-15T13:04:00.000Z", + }); + + expect(result.combat.round).toBe(2); + expect(result.combat.actingSide).toBe("player"); + expect(result.combat.player.hpCurrent).toBeLessThan(result.combat.player.hpMax); + expect(result.logEntries[0]?.text).toContain("attacks"); + }); + + it("rejects player turns that target a defeated enemy", () => { + const { adventurer, combat } = createGuardCombat(); + combat.enemies[0]!.hpCurrent = 0; + + expect(() => + resolvePlayerAttack({ + content: sampleContentPack, + combat, + adventurer, + manoeuvreId: "manoeuvre.exact-strike", + targetEnemyId: combat.enemies[0]!.id, + }), + ).toThrow("Unknown or defeated target"); + }); +}); diff --git a/src/rules/combatTurns.ts b/src/rules/combatTurns.ts new file mode 100644 index 0000000..953499c --- /dev/null +++ b/src/rules/combatTurns.ts @@ -0,0 +1,255 @@ +import type { + ArmourDefinition, + ContentPack, + ManoeuvreDefinition, + WeaponDefinition, +} from "@/types/content"; +import type { AdventurerState, CombatState, CombatantState } from "@/types/state"; +import type { LogEntry } from "@/types/rules"; + +import { roll2D6, type DiceRoller } from "./dice"; + +export type ResolvePlayerAttackOptions = { + content: ContentPack; + combat: CombatState; + adventurer: AdventurerState; + manoeuvreId: string; + targetEnemyId: string; + roller?: DiceRoller; + at?: string; +}; + +export type ResolveEnemyTurnOptions = { + content: ContentPack; + combat: CombatState; + adventurer: AdventurerState; + roller?: DiceRoller; + at?: string; +}; + +export type CombatTurnResult = { + combat: CombatState; + logEntries: LogEntry[]; + defeatedEnemyIds: string[]; + combatEnded: boolean; +}; + +const BASE_TARGET_NUMBER = 7; + +function requireWeapon(content: ContentPack, weaponId: string): WeaponDefinition { + const weapon = content.weapons.find((entry) => entry.id === weaponId); + + if (!weapon) { + throw new Error(`Unknown weapon id: ${weaponId}`); + } + + return weapon; +} + +function requireManoeuvre(content: ContentPack, manoeuvreId: string): ManoeuvreDefinition { + const manoeuvre = content.manoeuvres.find((entry) => entry.id === manoeuvreId); + + if (!manoeuvre) { + throw new Error(`Unknown manoeuvre id: ${manoeuvreId}`); + } + + return manoeuvre; +} + +function findArmour(content: ContentPack, armourId?: string): ArmourDefinition | undefined { + if (!armourId) { + return undefined; + } + + return content.armour.find((entry) => entry.id === armourId); +} + +function cloneCombatant(combatant: CombatantState): CombatantState { + return { + ...combatant, + statuses: [...combatant.statuses], + traits: [...combatant.traits], + }; +} + +function cloneCombat(combat: CombatState): CombatState { + return { + ...combat, + player: cloneCombatant(combat.player), + enemies: combat.enemies.map(cloneCombatant), + combatLog: combat.combatLog.map((entry) => ({ + ...entry, + relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined, + })), + }; +} + +function createLogEntry( + id: string, + at: string, + text: string, + relatedIds: string[], +): LogEntry { + return { + id, + at, + type: "combat", + text, + relatedIds, + }; +} + +function getPlayerArmourValue(content: ContentPack, adventurer: AdventurerState) { + return findArmour(content, adventurer.armourId)?.armourValue ?? 0; +} + +function isAlive(combatant: CombatantState) { + return combatant.hpCurrent > 0; +} + +function getLivingEnemies(combat: CombatState) { + return combat.enemies.filter(isAlive); +} + +export function resolvePlayerAttack( + options: ResolvePlayerAttackOptions, +): CombatTurnResult { + if (options.combat.actingSide !== "player") { + throw new Error("It is not currently the player's turn."); + } + + const combat = cloneCombat(options.combat); + const at = options.at ?? new Date().toISOString(); + const weapon = requireWeapon(options.content, options.adventurer.weaponId); + const manoeuvre = requireManoeuvre(options.content, options.manoeuvreId); + + if (!options.adventurer.manoeuvreIds.includes(manoeuvre.id)) { + throw new Error(`Adventurer cannot use manoeuvre ${manoeuvre.id}.`); + } + + if (!manoeuvre.weaponCategories.includes(weapon.category)) { + throw new Error(`Manoeuvre ${manoeuvre.id} is not compatible with ${weapon.id}.`); + } + + const target = combat.enemies.find((enemy) => enemy.id === options.targetEnemyId); + + if (!target || !isAlive(target)) { + throw new Error(`Unknown or defeated target enemy: ${options.targetEnemyId}`); + } + + const roll = roll2D6(options.roller); + const accuracy = + (roll.total ?? 0) + + combat.player.precision + + (manoeuvre.precisionModifier ?? 0); + const targetNumber = BASE_TARGET_NUMBER + (target.armourValue ?? 0); + const hit = accuracy >= targetNumber; + const rawDamage = hit + ? weapon.baseDamage + + Math.max(0, combat.player.discipline + (manoeuvre.damageModifier ?? 0)) + : 0; + const damage = hit ? Math.max(1, rawDamage) : 0; + + if (hit) { + target.hpCurrent = Math.max(0, target.hpCurrent - damage); + } + + combat.selectedManoeuvreId = manoeuvre.id; + combat.lastRoll = roll; + combat.actingSide = getLivingEnemies(combat).length === 0 ? "player" : "enemy"; + + const logEntries: LogEntry[] = [ + createLogEntry( + `${combat.id}.player.${combat.combatLog.length + 1}`, + at, + hit + ? `${combat.player.name} uses ${manoeuvre.name} on ${target.name}, rolls ${roll.total}, and deals ${damage} damage.` + : `${combat.player.name} uses ${manoeuvre.name} on ${target.name}, rolls ${roll.total}, and misses.`, + [combat.player.id, target.id], + ), + ]; + + const defeatedEnemyIds = !isAlive(target) ? [target.id] : []; + + if (defeatedEnemyIds.length > 0) { + logEntries.push( + createLogEntry( + `${combat.id}.player.${combat.combatLog.length + 2}`, + at, + `${target.name} is defeated.`, + [target.id], + ), + ); + } + + combat.combatLog.push(...logEntries); + + return { + combat, + logEntries, + defeatedEnemyIds, + combatEnded: getLivingEnemies(combat).length === 0, + }; +} + +export function resolveEnemyTurn( + options: ResolveEnemyTurnOptions, +): CombatTurnResult { + if (options.combat.actingSide !== "enemy") { + throw new Error("It is not currently the enemy's turn."); + } + + const combat = cloneCombat(options.combat); + const at = options.at ?? new Date().toISOString(); + const attacker = getLivingEnemies(combat)[0]; + + if (!attacker) { + throw new Error("No living enemies are available to act."); + } + + const roll = roll2D6(options.roller); + const armourValue = getPlayerArmourValue(options.content, options.adventurer); + const accuracy = (roll.total ?? 0) + attacker.precision; + const targetNumber = BASE_TARGET_NUMBER + armourValue; + const hit = accuracy >= targetNumber; + const rawDamage = hit ? Math.max(1, 1 + attacker.discipline) : 0; + + if (hit) { + combat.player.hpCurrent = Math.max(0, combat.player.hpCurrent - rawDamage); + } + + combat.lastRoll = roll; + combat.actingSide = combat.player.hpCurrent > 0 ? "player" : "enemy"; + combat.round += 1; + + const logEntries: LogEntry[] = [ + createLogEntry( + `${combat.id}.enemy.${combat.combatLog.length + 1}`, + at, + hit + ? `${attacker.name} attacks ${combat.player.name}, rolls ${roll.total}, and deals ${rawDamage} damage.` + : `${attacker.name} attacks ${combat.player.name}, rolls ${roll.total}, and misses.`, + [attacker.id, combat.player.id], + ), + ]; + + if (combat.player.hpCurrent === 0) { + logEntries.push( + createLogEntry( + `${combat.id}.enemy.${combat.combatLog.length + 2}`, + at, + `${combat.player.name} is defeated.`, + [combat.player.id], + ), + ); + } + + combat.combatLog.push(...logEntries); + + return { + combat, + logEntries, + defeatedEnemyIds: [], + combatEnded: combat.player.hpCurrent === 0, + }; +} From d504008030c1ba6f160a154df8761165e6985b45 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 13:50:50 -0500 Subject: [PATCH 7/7] =?UTF-8?q?=E2=9C=A8Feature:=20implement=20run=20state?= =?UTF-8?q?=20management=20with=20combat=20resolution=20and=20room=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rules/runState.test.ts | 186 ++++++++++++++++++++ src/rules/runState.ts | 335 +++++++++++++++++++++++++++++++++++++ 2 files changed, 521 insertions(+) create mode 100644 src/rules/runState.test.ts create mode 100644 src/rules/runState.ts diff --git a/src/rules/runState.test.ts b/src/rules/runState.test.ts new file mode 100644 index 0000000..31a3264 --- /dev/null +++ b/src/rules/runState.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; + +import { sampleContentPack } from "@/data/sampleContentPack"; + +import { createStartingAdventurer } from "./character"; +import { + createRunState, + enterCurrentRoom, + resolveRunEnemyTurn, + resolveRunPlayerTurn, + startCombatInCurrentRoom, +} 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.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("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]), + 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, + ); + }); +}); diff --git a/src/rules/runState.ts b/src/rules/runState.ts new file mode 100644 index 0000000..29da125 --- /dev/null +++ b/src/rules/runState.ts @@ -0,0 +1,335 @@ +import type { ContentPack } from "@/types/content"; +import type { + AdventurerState, + CombatState, + DungeonState, + RunState, +} from "@/types/state"; +import type { LogEntry } from "@/types/rules"; + +import { startCombatFromRoom } from "./combat"; +import { + resolveEnemyTurn, + resolvePlayerAttack, + type ResolveEnemyTurnOptions, + type ResolvePlayerAttackOptions, +} from "./combatTurns"; +import { initializeDungeonLevel } from "./dungeon"; +import type { DiceRoller } from "./dice"; +import { enterRoom } from "./roomEntry"; + +export type CreateRunOptions = { + content: ContentPack; + campaignId: string; + adventurer: AdventurerState; + runId?: string; + at?: string; +}; + +export type EnterCurrentRoomOptions = { + content: ContentPack; + run: RunState; + roller?: DiceRoller; + at?: string; +}; + +export type StartCurrentCombatOptions = { + content: ContentPack; + run: RunState; + at?: string; +}; + +export type ResolveRunPlayerTurnOptions = { + content: ContentPack; + run: RunState; + manoeuvreId: string; + targetEnemyId: string; + roller?: DiceRoller; + at?: string; +}; + +export type ResolveRunEnemyTurnOptions = { + content: ContentPack; + run: RunState; + roller?: DiceRoller; + at?: string; +}; + +export type RunTransitionResult = { + run: RunState; + logEntries: LogEntry[]; +}; + +function cloneCombat(combat: CombatState): CombatState { + return { + ...combat, + player: { + ...combat.player, + statuses: [...combat.player.statuses], + traits: [...combat.player.traits], + }, + enemies: combat.enemies.map((enemy) => ({ + ...enemy, + statuses: [...enemy.statuses], + traits: [...enemy.traits], + })), + combatLog: combat.combatLog.map((entry) => ({ + ...entry, + relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined, + })), + }; +} + +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], + }, + dungeon: { + levels: Object.fromEntries( + Object.entries(run.dungeon.levels).map(([level, levelState]) => [ + level, + { + ...levelState, + rooms: Object.fromEntries( + Object.entries(levelState.rooms).map(([roomId, room]) => [ + roomId, + { + ...room, + position: { ...room.position }, + dimensions: { ...room.dimensions }, + exits: room.exits.map((exit) => ({ ...exit })), + discovery: { ...room.discovery }, + encounter: room.encounter + ? { + ...room.encounter, + creatureIds: [...room.encounter.creatureIds], + creatureNames: room.encounter.creatureNames + ? [...room.encounter.creatureNames] + : undefined, + } + : undefined, + objects: room.objects.map((object) => ({ + ...object, + effects: object.effects ? [...object.effects] : undefined, + })), + notes: [...room.notes], + flags: [...room.flags], + }, + ]), + ), + discoveredRoomOrder: [...levelState.discoveredRoomOrder], + }, + ]), + ) as DungeonState["levels"], + revealedPercentByLevel: { ...run.dungeon.revealedPercentByLevel }, + globalFlags: [...run.dungeon.globalFlags], + }, + activeCombat: run.activeCombat ? cloneCombat(run.activeCombat) : undefined, + log: run.log.map((entry) => ({ + ...entry, + relatedIds: entry.relatedIds ? [...entry.relatedIds] : undefined, + })), + pendingEffects: run.pendingEffects.map((effect) => ({ ...effect })), + }; +} + +function requireCurrentLevel(run: RunState) { + const levelState = run.dungeon.levels[run.currentLevel]; + + if (!levelState) { + throw new Error(`Run is missing level ${run.currentLevel}.`); + } + + return levelState; +} + +function requireCurrentRoomId(run: RunState) { + if (!run.currentRoomId) { + throw new Error("Run does not have a current room."); + } + + return run.currentRoomId; +} + +function syncPlayerToAdventurer(run: RunState) { + if (!run.activeCombat) { + return; + } + + run.adventurerSnapshot.hp.current = run.activeCombat.player.hpCurrent; + run.adventurerSnapshot.statuses = run.activeCombat.player.statuses.map((status) => ({ ...status })); +} + +function appendLogs(run: RunState, logEntries: LogEntry[]) { + run.log.push(...logEntries); +} + +export function createRunState(options: CreateRunOptions): RunState { + const at = options.at ?? new Date().toISOString(); + const levelState = initializeDungeonLevel({ + content: options.content, + level: 1, + }); + + return { + id: options.runId ?? "run.active", + campaignId: options.campaignId, + status: "active", + startedAt: at, + currentLevel: 1, + currentRoomId: "room.level1.start", + dungeon: { + levels: { + 1: levelState, + }, + revealedPercentByLevel: { + 1: 0, + }, + globalFlags: [], + }, + adventurerSnapshot: options.adventurer, + log: [], + pendingEffects: [], + }; +} + +export function enterCurrentRoom( + options: EnterCurrentRoomOptions, +): RunTransitionResult { + const run = cloneRun(options.run); + const levelState = requireCurrentLevel(run); + const roomId = requireCurrentRoomId(run); + const entry = enterRoom({ + content: options.content, + levelState, + roomId, + roller: options.roller, + at: options.at, + }); + + run.dungeon.levels[run.currentLevel] = entry.levelState; + appendLogs(run, entry.logEntries); + + return { + run, + logEntries: entry.logEntries, + }; +} + +export function startCombatInCurrentRoom( + options: StartCurrentCombatOptions, +): RunTransitionResult { + const run = cloneRun(options.run); + const levelState = requireCurrentLevel(run); + const roomId = requireCurrentRoomId(run); + const room = levelState.rooms[roomId]; + + if (!room) { + throw new Error(`Unknown room id: ${roomId}`); + } + + const started = startCombatFromRoom({ + content: options.content, + adventurer: run.adventurerSnapshot, + room, + at: options.at, + }); + + levelState.rooms[roomId] = started.room; + run.activeCombat = started.combat; + appendLogs(run, started.logEntries); + + return { + run, + logEntries: started.logEntries, + }; +} + +export function resolveRunPlayerTurn( + options: ResolveRunPlayerTurnOptions, +): RunTransitionResult { + const run = cloneRun(options.run); + + if (!run.activeCombat) { + throw new Error("Run does not have an active combat."); + } + + const result = resolvePlayerAttack({ + content: options.content, + combat: run.activeCombat, + adventurer: run.adventurerSnapshot, + manoeuvreId: options.manoeuvreId, + targetEnemyId: options.targetEnemyId, + roller: options.roller, + at: options.at, + } satisfies ResolvePlayerAttackOptions); + + run.activeCombat = result.combat; + syncPlayerToAdventurer(run); + appendLogs(run, result.logEntries); + + if (result.combatEnded) { + const levelState = requireCurrentLevel(run); + const roomId = requireCurrentRoomId(run); + const room = levelState.rooms[roomId]; + + if (room?.encounter) { + room.encounter.rewardPending = false; + room.discovery.cleared = true; + } + + run.activeCombat = undefined; + } + + return { + run, + logEntries: result.logEntries, + }; +} + +export function resolveRunEnemyTurn( + options: ResolveRunEnemyTurnOptions, +): RunTransitionResult { + const run = cloneRun(options.run); + + if (!run.activeCombat) { + throw new Error("Run does not have an active combat."); + } + + const result = resolveEnemyTurn({ + content: options.content, + combat: run.activeCombat, + adventurer: run.adventurerSnapshot, + roller: options.roller, + at: options.at, + } satisfies ResolveEnemyTurnOptions); + + run.activeCombat = result.combat; + syncPlayerToAdventurer(run); + appendLogs(run, result.logEntries); + + if (result.combatEnded) { + run.status = "failed"; + } + + return { + run, + logEntries: result.logEntries, + }; +}