From da7b6fac8397849e7cfb52f217ec79b97e4c5578 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 08:56:44 -0500 Subject: [PATCH] feat(mystery): add Zod schemas for markdown frontmatter Defines runtime validation schemas (roomFrontmatterSchema, itemFrontmatterSchema, endingFrontmatterSchema, encounterFrontmatterSchema) and their inferred TypeScript types. All 8 TDD tests pass. Co-Authored-By: Claude Sonnet 4.6 --- src/world/schema.test.ts | 81 ++++++++++++++++++++++++++++++++++++++++ src/world/schema.ts | 57 ++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/world/schema.test.ts create mode 100644 src/world/schema.ts diff --git a/src/world/schema.test.ts b/src/world/schema.test.ts new file mode 100644 index 0000000..f3f581a --- /dev/null +++ b/src/world/schema.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest' +import { roomFrontmatterSchema, itemFrontmatterSchema, endingFrontmatterSchema, encounterFrontmatterSchema } from './schema' + +describe('roomFrontmatterSchema', () => { + it('accepts a fully populated room', () => { + const data = { + id: 'foyer', + title: '[ Foyer ]', + exitN: 'hallway', + exitS: null, + exitE: null, + exitW: null, + exitU: null, + exitD: null, + items: ['letter'], + encounter: null, + safe: true, + } + expect(() => roomFrontmatterSchema.parse(data)).not.toThrow() + }) + + it('accepts a locked exit with sibling fields', () => { + const data = { + id: 'hall', + title: '[ Hall ]', + exitN: null, exitS: null, exitE: null, exitW: null, exitU: null, + exitD: 'vault', + exitDRequires: 'rusted-key', + exitDLockedText: 'The door is locked.', + items: [], + } + expect(() => roomFrontmatterSchema.parse(data)).not.toThrow() + }) + + it('rejects a room missing a required exit field', () => { + const data = { id: 'r', title: '[ R ]', exitN: null, items: [] } + expect(() => roomFrontmatterSchema.parse(data)).toThrow() + }) +}) + +describe('itemFrontmatterSchema', () => { + it('accepts an item with state', () => { + const data = { + id: 'lamp', + names: ['lamp', 'oil lamp'], + short: 'an oil lamp', + takeable: true, + initialState: { lit: false }, + } + expect(() => itemFrontmatterSchema.parse(data)).not.toThrow() + }) + + it('accepts an item without state (defaults to {})', () => { + const parsed = itemFrontmatterSchema.parse({ + id: 'letter', + names: ['letter'], + short: 'a letter', + takeable: true, + }) + expect(parsed.initialState).toEqual({}) + }) +}) + +describe('endingFrontmatterSchema', () => { + it('accepts true ending shape', () => { + const data = { id: 'true', whenFlags: { ratGone: true } } + expect(() => endingFrontmatterSchema.parse(data)).not.toThrow() + }) + + it('rejects unknown ending id', () => { + const data = { id: 'mercy', whenFlags: {} } + expect(() => endingFrontmatterSchema.parse(data)).toThrow() + }) +}) + +describe('encounterFrontmatterSchema', () => { + it('accepts an encounter narration doc', () => { + const data = { id: 'rat', startsIn: 'cellar-stair', initialPhase: 'lurking' } + expect(() => encounterFrontmatterSchema.parse(data)).not.toThrow() + }) +}) diff --git a/src/world/schema.ts b/src/world/schema.ts new file mode 100644 index 0000000..f93f343 --- /dev/null +++ b/src/world/schema.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' + +const stateValueSchema = z.union([z.string(), z.boolean(), z.number()]) +const stateRecordSchema = z.record(z.string(), stateValueSchema) + +export const roomFrontmatterSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + exitN: z.string().nullable(), + exitS: z.string().nullable(), + exitE: z.string().nullable(), + exitW: z.string().nullable(), + exitU: z.string().nullable(), + exitD: z.string().nullable(), + exitNRequires: z.string().optional(), + exitNLockedText: z.string().optional(), + exitSRequires: z.string().optional(), + exitSLockedText: z.string().optional(), + exitERequires: z.string().optional(), + exitELockedText: z.string().optional(), + exitWRequires: z.string().optional(), + exitWLockedText: z.string().optional(), + exitURequires: z.string().optional(), + exitULockedText: z.string().optional(), + exitDRequires: z.string().optional(), + exitDLockedText: z.string().optional(), + items: z.array(z.string()).default([]), + encounter: z.string().nullable().optional(), + safe: z.boolean().optional(), +}) + +export type RoomFrontmatter = z.infer + +export const itemFrontmatterSchema = z.object({ + id: z.string().min(1), + names: z.array(z.string().min(1)).min(1), + short: z.string().min(1), + takeable: z.boolean(), + initialState: stateRecordSchema.default({}), +}) + +export type ItemFrontmatter = z.infer + +export const endingFrontmatterSchema = z.object({ + id: z.enum(['true', 'wrong', 'bad']), + whenFlags: stateRecordSchema.default({}), +}) + +export type EndingFrontmatter = z.infer + +export const encounterFrontmatterSchema = z.object({ + id: z.string().min(1), + startsIn: z.string().min(1), + initialPhase: z.string().min(1), +}) + +export type EncounterFrontmatter = z.infer