feat(world): parseItem extracts optional ## read / lit / extinguished / lighter-empty sections

Existing items with no body sections continue to load unchanged. New items
can author per-state prose in dedicated sections; the dispatcher will read
these in subsequent commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 14:06:29 -05:00
parent df50afa479
commit ee3cfcc00d
2 changed files with 146 additions and 4 deletions
+112
View File
@@ -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', () => { describe('parseRoom invalid headers', () => {
it('throws a clear error when a header has spaces', () => { it('throws a clear error when a header has spaces', () => {
const md = `--- const md = `---
+34 -4
View File
@@ -130,22 +130,52 @@ export function parseRoom(raw: string, sourcePath: string): Room {
return 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 { export function parseItem(raw: string, sourcePath: string): Item {
const parsed = matter(raw) const parsed = matter(raw)
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown> const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
const fm = itemFrontmatterSchema.parse(frontmatter) 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`) 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, id: fm.id,
names: fm.names, names: fm.names,
short: fm.short, short: fm.short,
long, long: longRaw,
initialState: fm.initialState, initialState: fm.initialState,
takeable: fm.takeable, 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 { export interface ParsedEnding {