From 1ff20723ec926f24a8e49bbc336a428b2961ae88 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 13:05:48 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8Feature:=20implement=20dungeon=20state?= =?UTF-8?q?=20management=20with=20room=20expansion=20and=20exit=20handling?= 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,