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:
@@ -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 = `---
|
||||
|
||||
+34
-4
@@ -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<string, unknown>
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user