fix(mystery): code-review followups (locked-exit, endings, headers)
- Validate lockedExits[*].requires resolves to a known item or flag - Throw if any of true/wrong/bad ending markdown files are missing - Detect malformed ## headers (spaces, dots, etc.) and throw a clear error rather than silently dropping the section Tests: 86 passing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -51,12 +51,31 @@ const endings: World['endings'] = {
|
||||
wrong: { whenFlags: {}, narration: '' },
|
||||
bad: { whenFlags: {}, narration: '' },
|
||||
}
|
||||
const seenEndings = new Set<string>()
|
||||
for (const [path, raw] of Object.entries(endingFiles)) {
|
||||
const { id, ending } = parseEnding(raw, path)
|
||||
endings[id] = ending
|
||||
seenEndings.add(id)
|
||||
}
|
||||
const requiredEndings = ['true', 'wrong', 'bad'] as const
|
||||
for (const id of requiredEndings) {
|
||||
if (!seenEndings.has(id)) {
|
||||
throw new Error(`endings/${id}.md is missing — every ending id must have a markdown file.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-reference validation.
|
||||
// Build set of all known flag names from encounter setFlags and ending whenFlags.
|
||||
const knownFlags = new Set<string>()
|
||||
for (const enc of Object.values(encounters)) {
|
||||
if (enc.onResolved?.setFlags) {
|
||||
for (const flagName of Object.keys(enc.onResolved.setFlags)) knownFlags.add(flagName)
|
||||
}
|
||||
}
|
||||
for (const ending of Object.values(endings)) {
|
||||
for (const flagName of Object.keys(ending.whenFlags)) knownFlags.add(flagName)
|
||||
}
|
||||
|
||||
for (const room of Object.values(rooms)) {
|
||||
for (const [dir, dest] of Object.entries(room.exits)) {
|
||||
if (!rooms[dest!]) {
|
||||
@@ -71,6 +90,20 @@ for (const room of Object.values(rooms)) {
|
||||
if (room.encounter && !encounters[room.encounter]) {
|
||||
throw new Error(`rooms/${room.id}.md: encounter "${room.encounter}" is not defined`)
|
||||
}
|
||||
if (room.lockedExits) {
|
||||
for (const [dir, lock] of Object.entries(room.lockedExits)) {
|
||||
const isItem = items[lock.requires] !== undefined
|
||||
const isFlag = knownFlags.has(lock.requires)
|
||||
if (!isItem && !isFlag) {
|
||||
const knownItemList = Object.keys(items).join(', ') || '(none)'
|
||||
const knownFlagList = [...knownFlags].join(', ') || '(none)'
|
||||
throw new Error(
|
||||
`rooms/${room.id}.md: exit${dir.toUpperCase()}Requires "${lock.requires}" matches no known item or flag. ` +
|
||||
`Known items: ${knownItemList}. Known flags: ${knownFlagList}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate encounter narration registry: every encounter in TS has a markdown doc.
|
||||
|
||||
@@ -310,3 +310,55 @@ describe('narration registry', () => {
|
||||
expect(() => narration('ghost', 'whatever')).toThrow(/unknown encounter id "ghost"/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseRoom invalid headers', () => {
|
||||
it('throws a clear error when a header has spaces', () => {
|
||||
const md = `---
|
||||
id: r
|
||||
title: "[ R ]"
|
||||
exitN: null
|
||||
exitS: null
|
||||
exitE: null
|
||||
exitW: null
|
||||
exitU: null
|
||||
exitD: null
|
||||
items: []
|
||||
---
|
||||
|
||||
## first visit
|
||||
This is the first visit.
|
||||
## revisit
|
||||
.
|
||||
## examined
|
||||
.
|
||||
`
|
||||
expect(() => parseRoom(md, 'rooms/r.md')).toThrow(
|
||||
/invalid section header "## first visit".*letters, digits, hyphens/,
|
||||
)
|
||||
})
|
||||
|
||||
it('throws a clear error when a header has dots', () => {
|
||||
const md = `---
|
||||
id: r
|
||||
title: "[ R ]"
|
||||
exitN: null
|
||||
exitS: null
|
||||
exitE: null
|
||||
exitW: null
|
||||
exitU: null
|
||||
exitD: null
|
||||
items: []
|
||||
---
|
||||
|
||||
## v2.0
|
||||
.
|
||||
## first-visit
|
||||
.
|
||||
## revisit
|
||||
.
|
||||
## examined
|
||||
.
|
||||
`
|
||||
expect(() => parseRoom(md, 'rooms/r.md')).toThrow(/invalid section header "## v2\.0"/)
|
||||
})
|
||||
})
|
||||
|
||||
+15
-2
@@ -40,13 +40,26 @@ function stripWikilink(value: unknown): unknown {
|
||||
|
||||
function splitSections(body: string): Record<string, string> {
|
||||
const sections: Record<string, string> = {}
|
||||
// Section names use only [A-Za-z0-9_-]; headers with spaces or dots are silently skipped.
|
||||
// First pass: detect ANY ## line that doesn't match the strict pattern.
|
||||
// Section names use only [a-zA-Z0-9_-]; headers with spaces or other characters
|
||||
// would silently fail without this check.
|
||||
const looseHeader = /^##[ \t]+(.+?)[ \t]*$/gm
|
||||
const strictHeader = /^([\w-]+)$/
|
||||
for (const m of body.matchAll(looseHeader)) {
|
||||
const headerText = m[1]!
|
||||
if (!strictHeader.test(headerText)) {
|
||||
throw new Error(
|
||||
`invalid section header "## ${headerText}": section names must contain only letters, digits, hyphens, and underscores`,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Second pass: extract sections (re-runs strict regex; same result as before).
|
||||
const re = /^##\s+([\w-]+)\s*$/gm
|
||||
const matches = [...body.matchAll(re)]
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const m = matches[i]!
|
||||
const key = m[1]!
|
||||
const start = m.index! + m[0]!.length
|
||||
const start = m.index! + m[0].length
|
||||
const end = i + 1 < matches.length ? matches[i + 1]!.index! : body.length
|
||||
sections[key] = body.slice(start, end).trim()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user