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 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<typeof roomFrontmatterSchema>
|
||||||
|
|
||||||
|
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<typeof itemFrontmatterSchema>
|
||||||
|
|
||||||
|
export const endingFrontmatterSchema = z.object({
|
||||||
|
id: z.enum(['true', 'wrong', 'bad']),
|
||||||
|
whenFlags: stateRecordSchema.default({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type EndingFrontmatter = z.infer<typeof endingFrontmatterSchema>
|
||||||
|
|
||||||
|
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<typeof encounterFrontmatterSchema>
|
||||||
Reference in New Issue
Block a user