diff --git a/src/world/index.ts b/src/world/index.ts index 9e0acab..fc38f95 100644 --- a/src/world/index.ts +++ b/src/world/index.ts @@ -51,12 +51,31 @@ const endings: World['endings'] = { wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' }, } +const seenEndings = new Set() 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() +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. diff --git a/src/world/loader.test.ts b/src/world/loader.test.ts index 0d8d04f..90e39f2 100644 --- a/src/world/loader.test.ts +++ b/src/world/loader.test.ts @@ -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"/) + }) +}) diff --git a/src/world/loader.ts b/src/world/loader.ts index 22d7cd2..75880bb 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -40,13 +40,26 @@ function stripWikilink(value: unknown): unknown { function splitSections(body: string): Record { const sections: Record = {} - // 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() }