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:
2026-05-09 11:12:26 -05:00
parent 4b8ebafe6f
commit 1f472402fd
3 changed files with 100 additions and 2 deletions
+33
View File
@@ -51,12 +51,31 @@ const endings: World['endings'] = {
wrong: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' },
bad: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' },
} }
const seenEndings = new Set<string>()
for (const [path, raw] of Object.entries(endingFiles)) { for (const [path, raw] of Object.entries(endingFiles)) {
const { id, ending } = parseEnding(raw, path) const { id, ending } = parseEnding(raw, path)
endings[id] = ending 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. // 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 room of Object.values(rooms)) {
for (const [dir, dest] of Object.entries(room.exits)) { for (const [dir, dest] of Object.entries(room.exits)) {
if (!rooms[dest!]) { if (!rooms[dest!]) {
@@ -71,6 +90,20 @@ for (const room of Object.values(rooms)) {
if (room.encounter && !encounters[room.encounter]) { if (room.encounter && !encounters[room.encounter]) {
throw new Error(`rooms/${room.id}.md: encounter "${room.encounter}" is not defined`) 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. // Validate encounter narration registry: every encounter in TS has a markdown doc.
+52
View File
@@ -310,3 +310,55 @@ describe('narration registry', () => {
expect(() => narration('ghost', 'whatever')).toThrow(/unknown encounter id "ghost"/i) 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
View File
@@ -40,13 +40,26 @@ function stripWikilink(value: unknown): unknown {
function splitSections(body: string): Record<string, string> { function splitSections(body: string): Record<string, string> {
const sections: 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 re = /^##\s+([\w-]+)\s*$/gm
const matches = [...body.matchAll(re)] const matches = [...body.matchAll(re)]
for (let i = 0; i < matches.length; i++) { for (let i = 0; i < matches.length; i++) {
const m = matches[i]! const m = matches[i]!
const key = m[1]! 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 const end = i + 1 < matches.length ? matches[i + 1]!.index! : body.length
sections[key] = body.slice(start, end).trim() sections[key] = body.slice(start, end).trim()
} }