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