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', () => {
|
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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user