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}`,