diff --git a/src/world/loader.test.ts b/src/world/loader.test.ts index 90e39f2..a6b923a 100644 --- a/src/world/loader.test.ts +++ b/src/world/loader.test.ts @@ -311,6 +311,118 @@ describe('narration registry', () => { }) }) +describe('parseItem — body sections', () => { + it('extracts ## read into readableText', () => { + const md = `--- +id: letter +names: [letter, note] +short: a folded letter +takeable: true +readable: true +--- + +A folded letter, sealed with wax. + +## read +You loved Halfstreet, the letter says. I loved it too. +` + const item = parseItem(md, 'items/letter.md') + expect(item.long).toBe('A folded letter, sealed with wax.') + expect(item.readable).toBe(true) + expect(item.readableText).toBe('You loved Halfstreet, the letter says. I loved it too.') + }) + + it('extracts ## lit and ## extinguished', () => { + const md = `--- +id: lamp +names: [lamp] +short: an oil lamp +takeable: true +lightable: true +initialState: + lit: false +--- + +An iron oil lamp. + +## lit +The wick catches; warm yellow light fills the space. + +## extinguished +You smother the flame. The room darkens. +` + const item = parseItem(md, 'items/lamp.md') + expect(item.long).toBe('An iron oil lamp.') + expect(item.litText).toBe('The wick catches; warm yellow light fills the space.') + expect(item.extinguishedText).toBe('You smother the flame. The room darkens.') + }) + + it('extracts ## lighter-empty', () => { + const md = `--- +id: matches +names: [matches] +short: a matchbook +takeable: true +lighter: true +lighterUses: 4 +--- + +A matchbook from the Halfstreet Hotel. + +## lighter-empty +The last match flares and dies. The book is empty. +` + const item = parseItem(md, 'items/matches.md') + expect(item.lighterEmptyText).toBe('The last match flares and dies. The book is empty.') + }) + + it('throws when readable: true but ## read is missing', () => { + const md = `--- +id: x +names: [x] +short: x +takeable: true +readable: true +--- + +A thing. +` + expect(() => parseItem(md, 'items/x.md')).toThrow(/## read.*required when readable/i) + }) + + it('still parses items with no body sections (back-compat)', () => { + const md = `--- +id: lamp +names: [lamp] +short: an oil lamp +takeable: true +--- + +An iron oil lamp with a glass chimney. +` + const item = parseItem(md, 'items/lamp.md') + expect(item.long).toBe('An iron oil lamp with a glass chimney.') + expect(item.readable).toBeUndefined() + expect(item.readableText).toBeUndefined() + }) + + it('throws for unknown section keys', () => { + const md = `--- +id: x +names: [x] +short: x +takeable: true +--- + +A thing. + +## badkey +Content. +` + expect(() => parseItem(md, 'items/x.md')).toThrow(/unknown item section "## badkey".*Allowed:.*read.*lit.*extinguished.*lighter-empty/i) + }) +}) + describe('parseRoom invalid headers', () => { it('throws a clear error when a header has spaces', () => { const md = `--- diff --git a/src/world/loader.ts b/src/world/loader.ts index d3fc545..48acec4 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -130,22 +130,52 @@ export function parseRoom(raw: string, sourcePath: string): Room { return room } +const ITEM_SECTION_KEYS = ['read', 'lit', 'extinguished', 'lighter-empty'] as const +type ItemSectionKey = typeof ITEM_SECTION_KEYS[number] + export function parseItem(raw: string, sourcePath: string): Item { const parsed = matter(raw) const frontmatter = stripWikilink(parsed.data) as Record const fm = itemFrontmatterSchema.parse(frontmatter) - const long = parsed.content.trim() - if (long.length === 0) { + + // Split body into long-description prefix + sectioned remainder. + // The first `## key` header (if any) marks the boundary. + const body = parsed.content + const firstHeader = body.match(/^##\s+[\w-]+\s*$/m) + const longRaw = firstHeader ? body.slice(0, firstHeader.index!).trim() : body.trim() + if (longRaw.length === 0) { throw new Error(`${sourcePath}: empty long description`) } - return { + const sections = firstHeader ? splitSections(body.slice(firstHeader.index!)) : {} + + // Validate that only known section keys appear. + for (const key of Object.keys(sections)) { + if (!ITEM_SECTION_KEYS.includes(key as ItemSectionKey)) { + throw new Error(`${sourcePath}: unknown item section "## ${key}". Allowed: ${ITEM_SECTION_KEYS.join(', ')}`) + } + } + + if (fm.readable && !sections['read']) { + throw new Error(`${sourcePath}: ## read section is required when readable: true`) + } + + const item: Item = { id: fm.id, names: fm.names, short: fm.short, - long, + long: longRaw, initialState: fm.initialState, takeable: fm.takeable, } + if (fm.readable !== undefined) item.readable = fm.readable + if (fm.lightable !== undefined) item.lightable = fm.lightable + if (fm.lighter !== undefined) item.lighter = fm.lighter + if (fm.lighterUses !== undefined) item.lighterUses = fm.lighterUses + if (sections['read']) item.readableText = sections['read'] + if (sections['lit']) item.litText = sections['lit'] + if (sections['extinguished']) item.extinguishedText = sections['extinguished'] + if (sections['lighter-empty']) item.lighterEmptyText = sections['lighter-empty'] + return item } export interface ParsedEnding {