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;