2026-05-09 08:56:44 -05:00
|
|
|
import { describe, it, expect } from 'vitest'
|
2026-05-13 17:59:13 -05:00
|
|
|
import {
|
|
|
|
|
gameFrontmatterSchema,
|
|
|
|
|
actionFrontmatterSchema,
|
|
|
|
|
lightMechanicFrontmatterSchema,
|
|
|
|
|
parserFrontmatterSchema,
|
|
|
|
|
uiFrontmatterSchema,
|
|
|
|
|
roomFrontmatterSchema,
|
|
|
|
|
itemFrontmatterSchema,
|
|
|
|
|
endingFrontmatterSchema,
|
|
|
|
|
encounterFrontmatterSchema,
|
|
|
|
|
} from './schema'
|
|
|
|
|
|
|
|
|
|
describe('gameFrontmatterSchema', () => {
|
|
|
|
|
it('accepts a markdown game manifest', () => {
|
|
|
|
|
const data = {
|
|
|
|
|
id: 'halfstreet',
|
|
|
|
|
title: 'Halfstreet',
|
|
|
|
|
description: 'A gothic mystery.',
|
|
|
|
|
startingRoom: 'outside-gate',
|
|
|
|
|
startingInventory: ['letter', 'matches'],
|
|
|
|
|
endingPriority: ['true', 'wrong'],
|
|
|
|
|
transcriptCap: 200,
|
|
|
|
|
}
|
|
|
|
|
expect(() => gameFrontmatterSchema.parse(data)).not.toThrow()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects an empty ending priority', () => {
|
|
|
|
|
const data = {
|
|
|
|
|
id: 'halfstreet',
|
|
|
|
|
title: 'Halfstreet',
|
|
|
|
|
description: 'A gothic mystery.',
|
|
|
|
|
startingRoom: 'outside-gate',
|
|
|
|
|
startingInventory: [],
|
|
|
|
|
endingPriority: [],
|
|
|
|
|
}
|
|
|
|
|
expect(() => gameFrontmatterSchema.parse(data)).toThrow()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('parserFrontmatterSchema', () => {
|
|
|
|
|
it('accepts markdown parser vocabulary', () => {
|
|
|
|
|
const data = {
|
|
|
|
|
directions: {
|
|
|
|
|
n: ['n', 'north'],
|
|
|
|
|
s: ['s', 'south'],
|
|
|
|
|
e: ['e', 'east'],
|
|
|
|
|
w: ['w', 'west'],
|
|
|
|
|
u: ['u', 'up'],
|
|
|
|
|
d: ['d', 'down'],
|
|
|
|
|
},
|
|
|
|
|
prepositions: ['with', 'on', 'in', 'to'],
|
|
|
|
|
stopWords: ['at', 'the', 'a', 'an'],
|
|
|
|
|
noTargetVerbs: ['look', 'inventory', 'wait', 'listen'],
|
|
|
|
|
metaVerbs: ['restart', 'undo', 'theme'],
|
|
|
|
|
verbs: {
|
|
|
|
|
go: ['go', 'walk'],
|
|
|
|
|
look: ['look', 'observe'],
|
|
|
|
|
take: ['take', 'pick up'],
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
expect(() => parserFrontmatterSchema.parse(data)).not.toThrow()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects unsupported verb keys', () => {
|
|
|
|
|
const data = {
|
|
|
|
|
directions: {
|
|
|
|
|
n: ['n'],
|
|
|
|
|
s: ['s'],
|
|
|
|
|
e: ['e'],
|
|
|
|
|
w: ['w'],
|
|
|
|
|
u: ['u'],
|
|
|
|
|
d: ['d'],
|
|
|
|
|
},
|
|
|
|
|
prepositions: ['with'],
|
|
|
|
|
verbs: { dance: ['dance'] },
|
|
|
|
|
}
|
|
|
|
|
expect(() => parserFrontmatterSchema.parse(data)).toThrow()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('uiFrontmatterSchema', () => {
|
|
|
|
|
it('accepts markdown UI config', () => {
|
|
|
|
|
const data = {
|
|
|
|
|
pageTitle: 'Halfstreet - Ethan J Lewis',
|
|
|
|
|
description: 'A gothic mystery.',
|
|
|
|
|
robots: 'noindex',
|
|
|
|
|
themeColor: '#1a0d00',
|
|
|
|
|
footer: {
|
|
|
|
|
copyright: '© 2026 Ethan J Lewis',
|
|
|
|
|
copyrightHref: 'https://ethanjlewis.com',
|
|
|
|
|
buildLabel: 'Build #',
|
|
|
|
|
showBuild: true,
|
|
|
|
|
links: [
|
|
|
|
|
{ label: 'GNU 3.0', href: 'https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE' },
|
|
|
|
|
{ label: 'Source Code', href: 'https://half.st/ejlewis/halfstreet' },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
features: {
|
|
|
|
|
chips: true,
|
|
|
|
|
lightMeter: true,
|
|
|
|
|
typedEffect: true,
|
|
|
|
|
roomScroll: true,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
expect(() => uiFrontmatterSchema.parse(data)).not.toThrow()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects invalid footer links', () => {
|
|
|
|
|
const data = {
|
|
|
|
|
pageTitle: 'Halfstreet',
|
|
|
|
|
description: 'A gothic mystery.',
|
|
|
|
|
footer: {
|
|
|
|
|
copyright: '© 2026 Ethan J Lewis',
|
|
|
|
|
links: [{ label: 'Source Code', href: '/relative' }],
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
expect(() => uiFrontmatterSchema.parse(data)).toThrow()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('lightMechanicFrontmatterSchema', () => {
|
|
|
|
|
it('accepts markdown light mechanic config', () => {
|
|
|
|
|
const data = {
|
|
|
|
|
enabled: true,
|
|
|
|
|
handler: 'light',
|
|
|
|
|
maxTurns: 4,
|
|
|
|
|
burnOn: ['move', 'wait'],
|
|
|
|
|
stateKeys: { lit: 'lit', burn: 'burn' },
|
|
|
|
|
ui: { meter: true, icon: 'candle' },
|
|
|
|
|
}
|
|
|
|
|
expect(() => lightMechanicFrontmatterSchema.parse(data)).not.toThrow()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects unknown burn triggers', () => {
|
|
|
|
|
const data = {
|
|
|
|
|
enabled: true,
|
|
|
|
|
handler: 'light',
|
|
|
|
|
maxTurns: 4,
|
|
|
|
|
burnOn: ['look'],
|
|
|
|
|
}
|
|
|
|
|
expect(() => lightMechanicFrontmatterSchema.parse(data)).toThrow()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('actionFrontmatterSchema', () => {
|
|
|
|
|
it('accepts a declarative action', () => {
|
|
|
|
|
const data = {
|
|
|
|
|
id: 'burn-letter',
|
|
|
|
|
verbs: ['use'],
|
|
|
|
|
requires: { allVisibleOrHeld: ['letter', 'matches'] },
|
|
|
|
|
consumes: { inventory: ['letter'] },
|
|
|
|
|
decrements: { item: 'matches', stateKey: 'uses' },
|
|
|
|
|
setsFlags: { letterBurned: true },
|
|
|
|
|
}
|
|
|
|
|
expect(() => actionFrontmatterSchema.parse(data)).not.toThrow()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts a handler-backed drunk transition action', () => {
|
|
|
|
|
const data = {
|
|
|
|
|
id: 'drink-whiskey',
|
|
|
|
|
verbs: ['drink'],
|
|
|
|
|
handler: 'drunk-transition',
|
|
|
|
|
requires: { allHeld: ['whiskey'] },
|
|
|
|
|
consumes: { inventory: ['whiskey'] },
|
|
|
|
|
drunkTransition: {
|
|
|
|
|
destinationRoom: 'drunk-hall',
|
|
|
|
|
maxMoves: 20,
|
|
|
|
|
wakeRoom: 'foyer',
|
|
|
|
|
resetRoom: 'kitchen',
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
expect(() => actionFrontmatterSchema.parse(data)).not.toThrow()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('requires drunk transition config for handler-backed drunk actions', () => {
|
|
|
|
|
const data = { id: 'drink-whiskey', verbs: ['drink'], handler: 'drunk-transition' }
|
|
|
|
|
expect(() => actionFrontmatterSchema.parse(data)).toThrow(/drunkTransition is required/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects drunk transition config without the matching handler', () => {
|
|
|
|
|
const data = {
|
|
|
|
|
id: 'drink-whiskey',
|
|
|
|
|
verbs: ['drink'],
|
|
|
|
|
drunkTransition: {
|
|
|
|
|
destinationRoom: 'drunk-hall',
|
|
|
|
|
maxMoves: 20,
|
|
|
|
|
wakeRoom: 'foyer',
|
|
|
|
|
resetRoom: 'kitchen',
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
expect(() => actionFrontmatterSchema.parse(data)).toThrow(/only supported when handler is drunk-transition/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects unsupported action verbs', () => {
|
|
|
|
|
const data = { id: 'dance-letter', verbs: ['dance'] }
|
|
|
|
|
expect(() => actionFrontmatterSchema.parse(data)).toThrow()
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-05-09 08:56:44 -05:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-13 17:59:13 -05:00
|
|
|
it('accepts custom ending ids', () => {
|
2026-05-12 20:22:20 -05:00
|
|
|
const data = { id: 'secret', whenFlags: {} }
|
2026-05-13 17:59:13 -05:00
|
|
|
expect(() => endingFrontmatterSchema.parse(data)).not.toThrow()
|
2026-05-09 08:56:44 -05:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('encounterFrontmatterSchema', () => {
|
|
|
|
|
it('accepts an encounter narration doc', () => {
|
|
|
|
|
const data = { id: 'rat', startsIn: 'cellar-stair', initialPhase: 'lurking' }
|
|
|
|
|
expect(() => encounterFrontmatterSchema.parse(data)).not.toThrow()
|
|
|
|
|
})
|
2026-05-13 17:59:13 -05:00
|
|
|
|
|
|
|
|
it('accepts markdown-owned encounter phases', () => {
|
|
|
|
|
const data = {
|
|
|
|
|
id: 'rat',
|
|
|
|
|
startsIn: 'cellar-stair',
|
|
|
|
|
initialPhase: 'lurking',
|
|
|
|
|
onResolved: { setFlags: { ratGone: true } },
|
|
|
|
|
defaultWrongVerbNarration: 'wrong-verb',
|
|
|
|
|
phases: {
|
|
|
|
|
lurking: {
|
|
|
|
|
description: 'lurking',
|
|
|
|
|
transitions: [
|
|
|
|
|
{
|
|
|
|
|
verb: 'attack',
|
|
|
|
|
target: 'rat',
|
|
|
|
|
chipLabel: 'ATTACK RAT',
|
|
|
|
|
chipCommand: 'attack rat',
|
|
|
|
|
narration: 'attack-rat-resolved',
|
|
|
|
|
to: 'resolved',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
expect(() => encounterFrontmatterSchema.parse(data)).not.toThrow()
|
|
|
|
|
})
|
2026-05-09 08:56:44 -05:00
|
|
|
})
|
2026-05-09 14:01:31 -05:00
|
|
|
|
|
|
|
|
describe('itemFrontmatterSchema — bible additions', () => {
|
|
|
|
|
it('accepts readable + lighter fields', () => {
|
|
|
|
|
const data = {
|
|
|
|
|
id: 'matches',
|
|
|
|
|
names: ['matches', 'matchbook'],
|
|
|
|
|
short: 'a matchbook',
|
|
|
|
|
takeable: true,
|
|
|
|
|
lighter: true,
|
|
|
|
|
lighterUses: 4,
|
|
|
|
|
}
|
|
|
|
|
expect(() => itemFrontmatterSchema.parse(data)).not.toThrow()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('accepts lightable on its own', () => {
|
|
|
|
|
const data = { id: 'lamp', names: ['lamp'], short: 'a lamp', takeable: true, lightable: true }
|
|
|
|
|
expect(() => itemFrontmatterSchema.parse(data)).not.toThrow()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects negative lighterUses', () => {
|
|
|
|
|
const data = { id: 'matches', names: ['matches'], short: 'matches', takeable: true, lighter: true, lighterUses: -1 }
|
|
|
|
|
expect(() => itemFrontmatterSchema.parse(data)).toThrow()
|
|
|
|
|
})
|
|
|
|
|
})
|