From 39703ce6b05ddb206f67b3ad771e7b510759ffa2 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 15 Mar 2026 13:11:28 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8Feature:=20enhance=20encounter=20resol?= =?UTF-8?q?ution=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; -- 2.49.1