Open source markdown authoring workflow
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-05-13 17:59:13 -05:00
parent 7b1b5d0f6c
commit 03482693ea
57 changed files with 4181 additions and 881 deletions
+226 -3
View File
@@ -1,5 +1,202 @@
import { describe, it, expect } from 'vitest'
import { roomFrontmatterSchema, itemFrontmatterSchema, endingFrontmatterSchema, encounterFrontmatterSchema } from './schema'
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()
})
})
describe('roomFrontmatterSchema', () => {
it('accepts a fully populated room', () => {
@@ -67,9 +264,9 @@ describe('endingFrontmatterSchema', () => {
expect(() => endingFrontmatterSchema.parse(data)).not.toThrow()
})
it('rejects unknown ending id', () => {
it('accepts custom ending ids', () => {
const data = { id: 'secret', whenFlags: {} }
expect(() => endingFrontmatterSchema.parse(data)).toThrow()
expect(() => endingFrontmatterSchema.parse(data)).not.toThrow()
})
})
@@ -78,6 +275,32 @@ describe('encounterFrontmatterSchema', () => {
const data = { id: 'rat', startsIn: 'cellar-stair', initialPhase: 'lurking' }
expect(() => encounterFrontmatterSchema.parse(data)).not.toThrow()
})
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()
})
})
describe('itemFrontmatterSchema — bible additions', () => {