From c0c1a7e9304d36f153d6b104f317ed597a13ec73 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:34:27 -0500 Subject: [PATCH] feat(mystery): assemble World from markdown via import.meta.glob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rooms, items, and endings now come from .md files under world/{rooms,items,endings}/. Encounters still come from encounters.ts (Tasks 11–12 will complete that leg). Cross-reference validation at module init ensures exits, item refs, and encounter refs are all consistent. Deletes rooms.ts, items.ts, roundtrip.test.ts, and the one-shot migration script (whose output is already committed). Co-Authored-By: Claude Sonnet 4.6 --- src/world/buildWorld.test.ts | 50 ++++++++++++++++++++ src/world/index.ts | 91 ++++++++++++++++++++++++++++++++++-- src/world/items.ts | 28 ----------- src/world/rooms.ts | 44 ----------------- src/world/roundtrip.test.ts | 71 ---------------------------- 5 files changed, 137 insertions(+), 147 deletions(-) create mode 100644 src/world/buildWorld.test.ts delete mode 100644 src/world/items.ts delete mode 100644 src/world/rooms.ts delete mode 100644 src/world/roundtrip.test.ts diff --git a/src/world/buildWorld.test.ts b/src/world/buildWorld.test.ts new file mode 100644 index 0000000..1493574 --- /dev/null +++ b/src/world/buildWorld.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { world } from './index' + +describe('assembled world', () => { + it('contains all three rooms', () => { + expect(Object.keys(world.rooms).sort()).toEqual(['cellar-stair', 'foyer', 'hallway']) + }) + + it('contains all three items', () => { + expect(Object.keys(world.items).sort()).toEqual(['lamp', 'letter', 'matches']) + }) + + it('all room exits resolve to known rooms', () => { + for (const room of Object.values(world.rooms)) { + for (const dest of Object.values(room.exits)) { + expect(world.rooms[dest], `${room.id} → ${dest}`).toBeDefined() + } + } + }) + + it('all room item refs resolve to known items', () => { + for (const room of Object.values(world.rooms)) { + for (const itemId of room.items) { + expect(world.items[itemId], `${room.id} item ${itemId}`).toBeDefined() + } + } + }) + + it('all room encounter refs resolve to known encounters', () => { + for (const room of Object.values(world.rooms)) { + if (room.encounter) { + expect(world.encounters[room.encounter]).toBeDefined() + } + } + }) + + it('startingRoom is a known room', () => { + expect(world.rooms[world.startingRoom]).toBeDefined() + }) + + it('startingInventory items are known', () => { + for (const itemId of world.startingInventory) { + expect(world.items[itemId]).toBeDefined() + } + }) + + it('endings have non-empty narration where the original did', () => { + expect(world.endings.true.narration.length).toBeGreaterThan(0) + }) +}) diff --git a/src/world/index.ts b/src/world/index.ts index a0118ba..9e0acab 100644 --- a/src/world/index.ts +++ b/src/world/index.ts @@ -1,8 +1,91 @@ -import type { World } from './types' -import { rooms } from './rooms' -import { items } from './items' +import type { World, Room, Item } from './types' +import { + parseRoom, + parseItem, + parseEnding, + parseEncounterNarration, +} from './loader' +// Importing loader (above) triggers auto-registration of encounter narrations. +// ESM evaluates dependencies first, so by the time encounters.ts is evaluated below, +// narration() can resolve all keys. import { encounters } from './encounters' -import { endings } from './story' + +const roomFiles = import.meta.glob('./rooms/*.md', { + eager: true, query: '?raw', import: 'default', +}) +const itemFiles = import.meta.glob('./items/*.md', { + eager: true, query: '?raw', import: 'default', +}) +const endingFiles = import.meta.glob('./endings/*.md', { + eager: true, query: '?raw', import: 'default', +}) +const encounterFiles = import.meta.glob('./encounters/*.md', { + eager: true, query: '?raw', import: 'default', +}) + +// Re-parse encounter docs here so we can validate startsIn / initialPhase against encounters.ts. +// (The loader already auto-registered narrations from these same files at module init.) +const encounterDocs = Object.entries(encounterFiles).map(([path, raw]) => + parseEncounterNarration(raw, path), +) + +// Build rooms map. +const rooms: Record = {} +for (const [path, raw] of Object.entries(roomFiles)) { + const room = parseRoom(raw, path) + if (rooms[room.id]) throw new Error(`${path}: duplicate room id "${room.id}"`) + rooms[room.id] = room +} + +// Build items map. +const items: Record = {} +for (const [path, raw] of Object.entries(itemFiles)) { + const item = parseItem(raw, path) + if (items[item.id]) throw new Error(`${path}: duplicate item id "${item.id}"`) + items[item.id] = item +} + +// Build endings. +const endings: World['endings'] = { + true: { whenFlags: {}, narration: '' }, + wrong: { whenFlags: {}, narration: '' }, + bad: { whenFlags: {}, narration: '' }, +} +for (const [path, raw] of Object.entries(endingFiles)) { + const { id, ending } = parseEnding(raw, path) + endings[id] = ending +} + +// Cross-reference validation. +for (const room of Object.values(rooms)) { + for (const [dir, dest] of Object.entries(room.exits)) { + if (!rooms[dest!]) { + throw new Error(`rooms/${room.id}.md: exit${dir.toUpperCase()} references "${dest}" but no such room exists.`) + } + } + for (const itemId of room.items) { + if (!items[itemId]) { + throw new Error(`rooms/${room.id}.md: items[] references unknown item "${itemId}"`) + } + } + if (room.encounter && !encounters[room.encounter]) { + throw new Error(`rooms/${room.id}.md: encounter "${room.encounter}" is not defined`) + } +} + +// Validate encounter narration registry: every encounter in TS has a markdown doc. +for (const enc of Object.values(encounters)) { + const doc = encounterDocs.find(d => d.id === enc.id) + if (!doc) { + throw new Error(`encounters/${enc.id}.md: missing narration markdown for encounter "${enc.id}"`) + } + if (doc.startsIn !== enc.startsIn) { + throw new Error(`encounters/${enc.id}.md: startsIn "${doc.startsIn}" does not match encounters.ts "${enc.startsIn}"`) + } + if (doc.initialPhase !== enc.initialPhase) { + throw new Error(`encounters/${enc.id}.md: initialPhase "${doc.initialPhase}" does not match encounters.ts "${enc.initialPhase}"`) + } +} export const world: World = { startingRoom: 'foyer', diff --git a/src/world/items.ts b/src/world/items.ts deleted file mode 100644 index 43b5915..0000000 --- a/src/world/items.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Item } from './types' - -export const items: Record = { - matches: { - id: 'matches', - names: ['matches', 'safety matches', 'box'], - short: 'a box of safety matches', - long: 'A small cardboard box of safety matches. Half-full.', - initialState: {}, - takeable: true, - }, - lamp: { - id: 'lamp', - names: ['lamp', 'oil lamp', 'torch'], - short: 'an oil lamp', - long: 'An iron oil lamp with a glass chimney. Currently unlit.', - initialState: { lit: false }, - takeable: true, - }, - letter: { - id: 'letter', - names: ['letter', 'folded letter', 'paper'], - short: 'a folded letter', - long: 'A folded letter on yellowed paper. The hand is unfamiliar. It reads: "Come at once. The thing in the cellar is waking."', - initialState: {}, - takeable: true, - }, -} diff --git a/src/world/rooms.ts b/src/world/rooms.ts deleted file mode 100644 index 06efd65..0000000 --- a/src/world/rooms.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Room } from './types' - -export const rooms: Record = { - foyer: { - id: 'foyer', - title: '[ Foyer ]', - descriptions: { - firstVisit: - 'You stand in the foyer of a house you do not remember entering. The door behind you has closed without sound. A folded letter lies on a small table. A hallway leads north.', - revisit: 'The foyer. The door behind you is still closed.', - examined: - 'A foyer with peeling paper. A small table holds nothing but the letter. The air smells of cold stone. A hallway leads north.', - }, - exits: { n: 'hallway' }, - items: ['letter'], - safe: true, - }, - hallway: { - id: 'hallway', - title: '[ Hallway ]', - descriptions: { - firstVisit: - 'A long hallway, lit by nothing. An iron oil lamp sits on a side table. The foyer is south. A stair descends east.', - revisit: 'The long hallway.', - examined: - 'The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. The oil lamp is on the side table.', - }, - exits: { s: 'foyer', e: 'cellar-stair' }, - items: ['lamp'], - }, - 'cellar-stair': { - id: 'cellar-stair', - title: '[ Cellar Stair ]', - descriptions: { - firstVisit: - 'The stair drops into wet stone. The hallway is west. Something at the bottom is breathing.', - revisit: 'The stair to the cellar.', - examined: 'The stairs are stone, slick with damp. You can hear water below, and something else.', - }, - exits: { w: 'hallway' }, - items: [], - encounter: 'rat', - }, -} diff --git a/src/world/roundtrip.test.ts b/src/world/roundtrip.test.ts deleted file mode 100644 index 6d9368f..0000000 --- a/src/world/roundtrip.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { parseRoom, parseItem, parseEnding, parseEncounterNarration } from './loader' -import { rooms } from './rooms' -import { items } from './items' -import { encounters } from './encounters' -import { endings } from './story' - -const roomFiles = import.meta.glob('./rooms/*.md', { - eager: true, query: '?raw', import: 'default', -}) -const itemFiles = import.meta.glob('./items/*.md', { - eager: true, query: '?raw', import: 'default', -}) -const endingFiles = import.meta.glob('./endings/*.md', { - eager: true, query: '?raw', import: 'default', -}) -const encounterFiles = import.meta.glob('./encounters/*.md', { - eager: true, query: '?raw', import: 'default', -}) - -describe('round-trip: rooms', () => { - it('parses each room file back to the original Room', () => { - for (const [path, raw] of Object.entries(roomFiles)) { - const parsed = parseRoom(raw, path) - const original = rooms[parsed.id] - expect(original, `room ${parsed.id} missing in source TS`).toBeDefined() - expect(parsed).toEqual(original) - } - }) -}) - -describe('round-trip: items', () => { - it('parses each item file back to the original Item', () => { - for (const [path, raw] of Object.entries(itemFiles)) { - const parsed = parseItem(raw, path) - const original = items[parsed.id] - expect(original, `item ${parsed.id} missing in source TS`).toBeDefined() - expect(parsed).toEqual(original) - } - }) -}) - -describe('round-trip: endings', () => { - it('parses each ending file back to the original ending', () => { - for (const [path, raw] of Object.entries(endingFiles)) { - const { id, ending } = parseEnding(raw, path) - const original = endings[id] - expect(ending.whenFlags).toEqual(original.whenFlags) - expect(ending.narration).toEqual(original.narration) - } - }) -}) - -describe('round-trip: encounters narration', () => { - it('captures every inline narration string', () => { - for (const [path, raw] of Object.entries(encounterFiles)) { - const doc = parseEncounterNarration(raw, path) - const original = encounters[doc.id] - expect(original).toBeDefined() - for (const [phaseName, phase] of Object.entries(original.phases)) { - expect(doc.narrations[phaseName], `phase ${phaseName} narration missing`).toBe(phase.description) - } - const allNarrations = new Set(Object.values(doc.narrations)) - for (const phase of Object.values(original.phases)) { - for (const t of phase.transitions) { - expect(allNarrations.has(t.narration), `transition narration missing: "${t.narration}"`).toBe(true) - } - } - } - }) -})