fix(mystery): handle aliased wikilinks; symmetric locked-exit validation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 09:09:38 -05:00
parent 5f3356ffb5
commit cf257c040a
2 changed files with 84 additions and 5 deletions
+75
View File
@@ -85,4 +85,79 @@ items: []
d: { requires: 'rusted-key', lockedNarration: 'The door is locked.' }, d: { requires: 'rusted-key', lockedNarration: 'The door is locked.' },
}) })
}) })
it('strips aliased wikilinks like [[id|display text]] to just the id', () => {
const md = `---
id: foyer
title: "[ Foyer ]"
exitN: "[[hallway|the long hallway]]"
exitS: null
exitE: null
exitW: null
exitU: null
exitD: null
items:
- "[[letter|the folded letter]]"
encounter: null
---
## first-visit
.
## revisit
.
## examined
.
`
const room = parseRoom(md, 'rooms/foyer.md')
expect(room.exits).toEqual({ n: 'hallway' })
expect(room.items).toEqual(['letter'])
})
it('throws when locked text is set without requires', () => {
const md = `---
id: r
title: "[ R ]"
exitN: null
exitS: null
exitE: null
exitW: null
exitU: null
exitD: "[[vault]]"
exitDLockedText: The door is locked.
items: []
---
## first-visit
.
## revisit
.
## examined
.
`
expect(() => parseRoom(md, 'rooms/r.md')).toThrow(/exitDLockedText is set but exitDRequires is missing/)
})
it('throws when requires is set without locked text', () => {
const md = `---
id: r
title: "[ R ]"
exitN: null
exitS: null
exitE: null
exitW: null
exitU: null
exitD: "[[vault]]"
exitDRequires: "[[rusted-key]]"
items: []
---
## first-visit
.
## revisit
.
## examined
.
`
expect(() => parseRoom(md, 'rooms/r.md')).toThrow(/exitDRequires is set but exitDLockedText is missing/)
})
}) })
+9 -5
View File
@@ -3,7 +3,7 @@ import type { Room, RoomDescriptions } from './types'
import type { Direction } from '../engine/types' import type { Direction } from '../engine/types'
import { roomFrontmatterSchema } from './schema' import { roomFrontmatterSchema } from './schema'
const WIKILINK = /^\[\[(.+)\]\]$/ const WIKILINK = /^\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$/
function stripWikilink(value: unknown): unknown { function stripWikilink(value: unknown): unknown {
if (typeof value === 'string') { if (typeof value === 'string') {
@@ -21,6 +21,7 @@ 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.
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++) {
@@ -72,10 +73,13 @@ export function parseRoom(raw: string, sourcePath: string): Room {
exits[dir] = dest exits[dir] = dest
const req = (fm as Record<string, unknown>)[keys.requires] as string | undefined const req = (fm as Record<string, unknown>)[keys.requires] as string | undefined
const locked = (fm as Record<string, unknown>)[keys.locked] as string | undefined const locked = (fm as Record<string, unknown>)[keys.locked] as string | undefined
if (req !== undefined) { if (req !== undefined && locked === undefined) {
if (locked === undefined) { throw new Error(`${sourcePath}: ${keys.requires} is set but ${keys.locked} is missing`)
throw new Error(`${sourcePath}: ${keys.requires} is set but ${keys.locked} is missing`) }
} if (locked !== undefined && req === undefined) {
throw new Error(`${sourcePath}: ${keys.locked} is set but ${keys.requires} is missing`)
}
if (req !== undefined && locked !== undefined) {
lockedExits[dir] = { requires: req, lockedNarration: locked } lockedExits[dir] = { requires: req, lockedNarration: locked }
} }
} }