feat(mystery): assemble World from markdown via import.meta.glob
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
+87
-4
@@ -1,8 +1,91 @@
|
|||||||
import type { World } from './types'
|
import type { World, Room, Item } from './types'
|
||||||
import { rooms } from './rooms'
|
import {
|
||||||
import { items } from './items'
|
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 { encounters } from './encounters'
|
||||||
import { endings } from './story'
|
|
||||||
|
const roomFiles = import.meta.glob<string>('./rooms/*.md', {
|
||||||
|
eager: true, query: '?raw', import: 'default',
|
||||||
|
})
|
||||||
|
const itemFiles = import.meta.glob<string>('./items/*.md', {
|
||||||
|
eager: true, query: '?raw', import: 'default',
|
||||||
|
})
|
||||||
|
const endingFiles = import.meta.glob<string>('./endings/*.md', {
|
||||||
|
eager: true, query: '?raw', import: 'default',
|
||||||
|
})
|
||||||
|
const encounterFiles = import.meta.glob<string>('./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<string, Room> = {}
|
||||||
|
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<string, Item> = {}
|
||||||
|
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 = {
|
export const world: World = {
|
||||||
startingRoom: 'foyer',
|
startingRoom: 'foyer',
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { Item } from './types'
|
|
||||||
|
|
||||||
export const items: Record<string, Item> = {
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import type { Room } from './types'
|
|
||||||
|
|
||||||
export const rooms: Record<string, Room> = {
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -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<string>('./rooms/*.md', {
|
|
||||||
eager: true, query: '?raw', import: 'default',
|
|
||||||
})
|
|
||||||
const itemFiles = import.meta.glob<string>('./items/*.md', {
|
|
||||||
eager: true, query: '?raw', import: 'default',
|
|
||||||
})
|
|
||||||
const endingFiles = import.meta.glob<string>('./endings/*.md', {
|
|
||||||
eager: true, query: '?raw', import: 'default',
|
|
||||||
})
|
|
||||||
const encounterFiles = import.meta.glob<string>('./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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user