From cf257c040a3c05e5683f477608e119a06bfe26be Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:09:38 -0500 Subject: [PATCH] fix(mystery): handle aliased wikilinks; symmetric locked-exit validation Co-Authored-By: Claude Sonnet 4.6 --- src/world/loader.test.ts | 75 ++++++++++++++++++++++++++++++++++++++++ src/world/loader.ts | 14 +++++--- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/world/loader.test.ts b/src/world/loader.test.ts index f562084..e6114da 100644 --- a/src/world/loader.test.ts +++ b/src/world/loader.test.ts @@ -85,4 +85,79 @@ items: [] 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/) + }) }) diff --git a/src/world/loader.ts b/src/world/loader.ts index 20d4240..100469e 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -3,7 +3,7 @@ import type { Room, RoomDescriptions } from './types' import type { Direction } from '../engine/types' import { roomFrontmatterSchema } from './schema' -const WIKILINK = /^\[\[(.+)\]\]$/ +const WIKILINK = /^\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$/ function stripWikilink(value: unknown): unknown { if (typeof value === 'string') { @@ -21,6 +21,7 @@ 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. const re = /^##\s+([\w-]+)\s*$/gm const matches = [...body.matchAll(re)] for (let i = 0; i < matches.length; i++) { @@ -72,10 +73,13 @@ export function parseRoom(raw: string, sourcePath: string): Room { exits[dir] = dest const req = (fm as Record)[keys.requires] as string | undefined const locked = (fm as Record)[keys.locked] as string | undefined - if (req !== undefined) { - if (locked === undefined) { - throw new Error(`${sourcePath}: ${keys.requires} is set but ${keys.locked} is missing`) - } + if (req !== undefined && locked === undefined) { + 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 } } }