This commit is contained in:
@@ -22,6 +22,28 @@ npm run dev # local dev server
|
|||||||
npm run build # type-check + production build
|
npm run build # type-check + production build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Make Your Own Game
|
||||||
|
|
||||||
|
Halfstreet is currently meant to be forked as a complete Astro app, not consumed
|
||||||
|
as a separate engine package. To make a new story, replace the markdown vault in
|
||||||
|
`src/world/` and keep the TypeScript runtime in place.
|
||||||
|
|
||||||
|
Start with:
|
||||||
|
|
||||||
|
- `src/world/game.md` for the title, starting room, starting inventory, ending
|
||||||
|
priority, opening art, help text, and end text.
|
||||||
|
- `src/world/parser.md` for command vocabulary and aliases.
|
||||||
|
- `src/world/rooms/`, `src/world/items/`, `src/world/encounters/`, and
|
||||||
|
`src/world/endings/` for story content.
|
||||||
|
- `src/world/mechanics/` and `src/world/actions/` for configurable rules and
|
||||||
|
interactions.
|
||||||
|
- `src/world/ui.md` for page metadata, footer links, and UI feature switches.
|
||||||
|
- `src/world/templates/` for starter files.
|
||||||
|
|
||||||
|
Run `npm test` after changing world files. The loader validates wikilinks,
|
||||||
|
required sections, frontmatter shape, and references between rooms, items,
|
||||||
|
encounters, endings, mechanics, and actions.
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
The footer build number comes from Woodpecker's pipeline number and increments on each CI build.
|
The footer build number comes from Woodpecker's pipeline number and increments on each CI build.
|
||||||
@@ -42,7 +64,7 @@ Each release script updates `package.json` and `package-lock.json`, creates a re
|
|||||||
|
|
||||||
- `src/engine/` — parser, dispatcher, encounter logic
|
- `src/engine/` — parser, dispatcher, encounter logic
|
||||||
- `src/ui/` — terminal renderer, theme, chips
|
- `src/ui/` — terminal renderer, theme, chips
|
||||||
- `src/world/` — markdown content (rooms, items, encounters, endings)
|
- `src/world/` — Obsidian-friendly authoring vault
|
||||||
- `src/pages/index.astro` — entry page
|
- `src/pages/index.astro` — entry page
|
||||||
|
|
||||||
## Design docs
|
## Design docs
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "halfstreet",
|
"name": "halfstreet",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "halfstreet",
|
"name": "halfstreet",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^6.1.9",
|
"astro": "^6.1.9",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "halfstreet",
|
"name": "halfstreet",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.12.0"
|
"node": ">=22.12.0"
|
||||||
|
|||||||
@@ -326,6 +326,79 @@ describe('light status', () => {
|
|||||||
maxTurns: 6,
|
maxTurns: 6,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the configured light meter length and state keys', () => {
|
||||||
|
const lightWorld: World = {
|
||||||
|
...world,
|
||||||
|
mechanics: {
|
||||||
|
light: {
|
||||||
|
enabled: true,
|
||||||
|
handler: 'light',
|
||||||
|
maxTurns: 3,
|
||||||
|
burnOn: ['wait'],
|
||||||
|
stateKeys: { lit: 'isLit', burn: 'fuel' },
|
||||||
|
ui: { meter: true, icon: 'candle' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
...world.items,
|
||||||
|
torch: {
|
||||||
|
id: 'torch',
|
||||||
|
names: ['torch', 'lamp'],
|
||||||
|
short: 'an oil lamp',
|
||||||
|
long: 'An iron oil lamp, unlit.',
|
||||||
|
initialState: { isLit: false },
|
||||||
|
takeable: true,
|
||||||
|
lightable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const state: GameState = {
|
||||||
|
...initialStateFor(lightWorld),
|
||||||
|
inventory: [{ id: 'torch', state: { isLit: true, fuel: 2 } }],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getLightStatus(state, lightWorld)).toEqual({
|
||||||
|
itemId: 'torch',
|
||||||
|
lit: true,
|
||||||
|
turnsLeft: 2,
|
||||||
|
maxTurns: 3,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the meter when the light mechanic is disabled', () => {
|
||||||
|
const lightWorld: World = {
|
||||||
|
...world,
|
||||||
|
mechanics: {
|
||||||
|
light: {
|
||||||
|
enabled: false,
|
||||||
|
handler: 'light',
|
||||||
|
maxTurns: 6,
|
||||||
|
burnOn: ['move', 'wait'],
|
||||||
|
stateKeys: { lit: 'lit', burn: 'burn' },
|
||||||
|
ui: { meter: true, icon: 'candle' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
...world.items,
|
||||||
|
torch: {
|
||||||
|
id: 'torch',
|
||||||
|
names: ['torch', 'lamp'],
|
||||||
|
short: 'an oil lamp',
|
||||||
|
long: 'An iron oil lamp, unlit.',
|
||||||
|
initialState: { lit: false },
|
||||||
|
takeable: true,
|
||||||
|
lightable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const state: GameState = {
|
||||||
|
...initialStateFor(lightWorld),
|
||||||
|
inventory: [{ id: 'torch', state: { lit: true, burn: 6 } }],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getLightStatus(state, lightWorld)).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('ambiguous → disambiguation flow', () => {
|
describe('ambiguous → disambiguation flow', () => {
|
||||||
@@ -553,6 +626,100 @@ describe('light/extinguish verbs (implicit lighter)', () => {
|
|||||||
expect(result.state.location).toBe('r2')
|
expect(result.state.location).toBe('r2')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses configured maxTurns when lighting and burning down', () => {
|
||||||
|
const baseWorld = w()
|
||||||
|
const world: World = {
|
||||||
|
...baseWorld,
|
||||||
|
mechanics: {
|
||||||
|
light: {
|
||||||
|
enabled: true,
|
||||||
|
handler: 'light',
|
||||||
|
maxTurns: 3,
|
||||||
|
burnOn: ['wait'],
|
||||||
|
stateKeys: { lit: 'lit', burn: 'burn' },
|
||||||
|
ui: { meter: true, icon: 'candle' },
|
||||||
|
messages: { flameDies: 'The configured light dies.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
...baseWorld.items,
|
||||||
|
lamp: {
|
||||||
|
id: 'lamp',
|
||||||
|
names: ['lamp'],
|
||||||
|
short: 'an oil lamp',
|
||||||
|
long: '.',
|
||||||
|
initialState: { lit: false },
|
||||||
|
takeable: true,
|
||||||
|
lightable: true,
|
||||||
|
litText: 'The wick catches.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [
|
||||||
|
{ id: 'lamp', state: { lit: false } },
|
||||||
|
{ id: 'matches', state: { uses: 2 } },
|
||||||
|
] }
|
||||||
|
|
||||||
|
const lit = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world)
|
||||||
|
expect(lit.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(3)
|
||||||
|
|
||||||
|
const first = dispatch(lit.state, { kind: 'verb-only', verb: 'wait' }, world)
|
||||||
|
expect(first.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(2)
|
||||||
|
const second = dispatch(first.state, { kind: 'verb-only', verb: 'wait' }, world)
|
||||||
|
expect(second.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(1)
|
||||||
|
const third = dispatch(second.state, { kind: 'verb-only', verb: 'wait' }, world)
|
||||||
|
expect(third.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(false)
|
||||||
|
expect(third.appended.map((l) => l.text)).toContain('The configured light dies.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not burn down on movement when move is not configured', () => {
|
||||||
|
const world: World = {
|
||||||
|
...w(),
|
||||||
|
mechanics: {
|
||||||
|
light: {
|
||||||
|
enabled: true,
|
||||||
|
handler: 'light',
|
||||||
|
maxTurns: 3,
|
||||||
|
burnOn: ['wait'],
|
||||||
|
stateKeys: { lit: 'lit', burn: 'burn' },
|
||||||
|
ui: { meter: true, icon: 'candle' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rooms: {
|
||||||
|
r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: { n: 'r2' }, items: [] },
|
||||||
|
r2: { id: 'r2', title: '[ R2 ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'lamp', state: { lit: true, burn: 3 } }] }
|
||||||
|
|
||||||
|
const result = dispatch(state, { kind: 'go', direction: 'n' }, world)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disabling the light mechanic removes burn-down behavior', () => {
|
||||||
|
const world: World = {
|
||||||
|
...w(),
|
||||||
|
mechanics: {
|
||||||
|
light: {
|
||||||
|
enabled: false,
|
||||||
|
handler: 'light',
|
||||||
|
maxTurns: 6,
|
||||||
|
burnOn: ['move', 'wait'],
|
||||||
|
stateKeys: { lit: 'lit', burn: 'burn' },
|
||||||
|
ui: { meter: true, icon: 'candle' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'lamp', state: { lit: true, burn: 1 } }] }
|
||||||
|
|
||||||
|
const result = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state).toEqual({ lit: true, burn: 1 })
|
||||||
|
expect(result.appended.map((l) => l.text)).not.toContain('The flame dies.')
|
||||||
|
})
|
||||||
|
|
||||||
it('extinguishes a lit lamp', () => {
|
it('extinguishes a lit lamp', () => {
|
||||||
const world = w()
|
const world = w()
|
||||||
let state = initialStateFor(world)
|
let state = initialStateFor(world)
|
||||||
@@ -633,6 +800,21 @@ describe('use verb routing', () => {
|
|||||||
letter: { id: 'letter', names: ['letter'], short: 'a letter', long: '.', initialState: {}, takeable: true, readable: true, readableText: 'Read me.' },
|
letter: { id: 'letter', names: ['letter'], short: 'a letter', long: '.', initialState: {}, takeable: true, readable: true, readableText: 'Read me.' },
|
||||||
'broken-cigarette': { id: 'broken-cigarette', names: ['cigarette', 'broken cigarette'], short: 'a broken cigarette', long: '.', initialState: { lit: false }, takeable: true, lightable: true, litText: 'The end glows once, then steadies. The smoke is bitter.' },
|
'broken-cigarette': { id: 'broken-cigarette', names: ['cigarette', 'broken cigarette'], short: 'a broken cigarette', long: '.', initialState: { lit: false }, takeable: true, lightable: true, litText: 'The end glows once, then steadies. The smoke is bitter.' },
|
||||||
},
|
},
|
||||||
|
actions: {
|
||||||
|
'burn-letter': {
|
||||||
|
id: 'burn-letter',
|
||||||
|
verbs: ['use'],
|
||||||
|
requires: { allVisibleOrHeld: ['letter', 'matches'] },
|
||||||
|
consumes: { inventory: ['letter'] },
|
||||||
|
decrements: { item: 'matches', stateKey: 'uses' },
|
||||||
|
setsFlags: { letterBurned: true },
|
||||||
|
messages: {
|
||||||
|
success: 'The letter catches at one corner. In a few breaths it is ash.',
|
||||||
|
spent: 'The matchbook is empty.',
|
||||||
|
missingRequired: "You don't see the letter here.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
encounters: {},
|
encounters: {},
|
||||||
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
||||||
}
|
}
|
||||||
@@ -695,6 +877,77 @@ describe('use verb routing', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('handler-backed drink action', () => {
|
||||||
|
function w(): World {
|
||||||
|
return {
|
||||||
|
startingRoom: 'r',
|
||||||
|
startingInventory: ['whiskey'],
|
||||||
|
rooms: {
|
||||||
|
r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] },
|
||||||
|
'drunk-start': { id: 'drunk-start', title: '[ Drunk Start ]', descriptions: { firstVisit: 'The hall tips.', revisit: 'The hall tips again.', examined: '.' }, exits: { n: 'drunk-next' }, items: [] },
|
||||||
|
'drunk-next': { id: 'drunk-next', title: '[ Drunk Next ]', descriptions: { firstVisit: 'The room doubles.', revisit: 'The room doubles again.', examined: '.' }, exits: { s: 'drunk-start' }, items: [] },
|
||||||
|
vestibule: { id: 'vestibule', title: '[ Vestibule ]', descriptions: { firstVisit: '.', revisit: 'You wake somewhere else.', examined: '.' }, exits: {}, items: [] },
|
||||||
|
pantry: { id: 'pantry', title: '[ Pantry ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: ['whiskey'] },
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
whiskey: { id: 'whiskey', names: ['whiskey'], short: 'a bottle of whiskey', long: '.', initialState: {}, takeable: true },
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
'drink-whiskey': {
|
||||||
|
id: 'drink-whiskey',
|
||||||
|
verbs: ['drink'],
|
||||||
|
handler: 'drunk-transition',
|
||||||
|
requires: { allHeld: ['whiskey'] },
|
||||||
|
consumes: { inventory: ['whiskey'] },
|
||||||
|
drunkTransition: {
|
||||||
|
destinationRoom: 'drunk-start',
|
||||||
|
maxMoves: 2,
|
||||||
|
wakeRoom: 'vestibule',
|
||||||
|
resetRoom: 'pantry',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
success: 'Custom drink text.',
|
||||||
|
missingRequired: 'Hold it first.',
|
||||||
|
tooManyMovesPassOut: 'Custom pass out.',
|
||||||
|
reset: 'Custom reset.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
encounters: {},
|
||||||
|
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('uses markdown action config for destination, move cap, wake room, and reset room', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
let result = dispatch(state, { kind: 'verb-target', verb: 'drink', target: { canonical: 'whiskey', raw: 'whiskey' } }, world)
|
||||||
|
|
||||||
|
expect(result.state.location).toBe('drunk-start')
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'whiskey')).toBeUndefined()
|
||||||
|
expect(result.appended.map((l) => l.text)).toContain('Custom drink text.')
|
||||||
|
|
||||||
|
state = {
|
||||||
|
...result.state,
|
||||||
|
roomState: {
|
||||||
|
...result.state.roomState,
|
||||||
|
pantry: { takenItems: ['whiskey'], droppedItems: ['whiskey'] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = dispatch(state, { kind: 'go', direction: 'n' }, world)
|
||||||
|
expect(result.state.location).toBe('drunk-next')
|
||||||
|
expect(result.state.flags['drunkMoves']).toBe(1)
|
||||||
|
|
||||||
|
result = dispatch(result.state, { kind: 'go', direction: 's' }, world)
|
||||||
|
expect(result.state.location).toBe('vestibule')
|
||||||
|
expect(result.state.flags['drunk']).toBe(false)
|
||||||
|
expect(result.state.roomState['pantry']?.['takenItems']).toEqual([])
|
||||||
|
expect(result.state.roomState['pantry']?.['droppedItems']).toEqual([])
|
||||||
|
expect(result.appended.map((l) => l.text)).toContain('Custom pass out.')
|
||||||
|
expect(result.appended.map((l) => l.text)).toContain('Custom reset.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('ending detection', () => {
|
describe('ending detection', () => {
|
||||||
function makeWorld(): World {
|
function makeWorld(): World {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+286
-152
@@ -1,10 +1,47 @@
|
|||||||
import type { World } from '../world/types'
|
import { DEFAULT_WORLD_MESSAGES, type DeclarativeAction, type LightMechanicMessageKey, type World, type WorldMessageKey } from '../world/types'
|
||||||
import type { GameState, ParsedCommand, DispatchResult, ItemInstance, TranscriptLine } from './types'
|
import type { GameState, ParsedCommand, DispatchResult, ItemInstance, TranscriptLine, ResolveLevel } from './types'
|
||||||
import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
|
import { SCHEMA_VERSION, TRANSCRIPT_CAP, RESOLVE_LEVELS } from './types'
|
||||||
import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters'
|
import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters'
|
||||||
|
|
||||||
export const LIGHT_TURNS_MAX = 6
|
type ActiveLightMechanic = NonNullable<NonNullable<World['mechanics']>['light']>
|
||||||
const DRUNK_TURNS_MAX = 20
|
type ActiveResolveMechanic = NonNullable<NonNullable<World['mechanics']>['resolve']>
|
||||||
|
|
||||||
|
const DEFAULT_LIGHT_MECHANIC: ActiveLightMechanic = {
|
||||||
|
enabled: true,
|
||||||
|
handler: 'light',
|
||||||
|
maxTurns: 6,
|
||||||
|
burnOn: ['move', 'wait'],
|
||||||
|
stateKeys: { lit: 'lit', burn: 'burn' },
|
||||||
|
ui: { meter: true, icon: 'candle' },
|
||||||
|
messages: {},
|
||||||
|
}
|
||||||
|
const DEFAULT_RESOLVE_MECHANIC: ActiveResolveMechanic = {
|
||||||
|
enabled: true,
|
||||||
|
handler: 'resolve',
|
||||||
|
ladder: RESOLVE_LEVELS,
|
||||||
|
wrongVerbCost: 1,
|
||||||
|
safeRooms: { recoverySteps: 1 },
|
||||||
|
failure: { retreatAt: 'returning', afterRetreat: 'shaken' },
|
||||||
|
}
|
||||||
|
const DEFAULT_DRUNK_ACTION: DeclarativeAction = {
|
||||||
|
id: 'drink-whiskey',
|
||||||
|
verbs: ['drink'],
|
||||||
|
handler: 'drunk-transition',
|
||||||
|
requires: { allHeld: ['whiskey'] },
|
||||||
|
consumes: { inventory: ['whiskey'] },
|
||||||
|
drunkTransition: {
|
||||||
|
destinationRoom: 'drunk-hall',
|
||||||
|
maxMoves: 20,
|
||||||
|
wakeRoom: 'foyer',
|
||||||
|
resetRoom: 'kitchen',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
success: 'You drink from the bottle. It tastes of smoke, sugar, and rainwater left too long in a pipe.',
|
||||||
|
secretFoundPassOut: 'The faceless man steps backward into the dark. The floor rises under you, or you fall toward it.',
|
||||||
|
tooManyMovesPassOut: 'The rooms keep turning until they become one room. Then even that room is gone.',
|
||||||
|
reset: 'The bottle is not with you. Somewhere in the kitchen, it is half full again.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export interface LightStatus {
|
export interface LightStatus {
|
||||||
itemId: string
|
itemId: string
|
||||||
@@ -13,13 +50,34 @@ export interface LightStatus {
|
|||||||
maxTurns: number
|
maxTurns: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const HALFSTREET_ASCII = String.raw`
|
function message(world: World, key: WorldMessageKey): string {
|
||||||
_ _ _ __ ____ _ _
|
return world.messages?.[key] ?? DEFAULT_WORLD_MESSAGES[key]
|
||||||
| | | | __ _| |/ _| / ___|| |_ _ __ ___ ___| |_
|
}
|
||||||
| |_| |/ _\` | | |_ \___ \| __| '__/ _ \/ _ \ __|
|
|
||||||
| _ | (_| | | _| ___) | |_| | | __/ __/ |_
|
function lightMechanic(world: World): ActiveLightMechanic {
|
||||||
|_| |_|\__,_|_|_| |____/ \__|_| \___|\___|\__|
|
return world.mechanics?.light ?? DEFAULT_LIGHT_MECHANIC
|
||||||
`.trim()
|
}
|
||||||
|
|
||||||
|
function resolveMechanic(world: World): ActiveResolveMechanic {
|
||||||
|
return world.mechanics?.resolve ?? DEFAULT_RESOLVE_MECHANIC
|
||||||
|
}
|
||||||
|
|
||||||
|
function drunkAction(world: World): DeclarativeAction {
|
||||||
|
return Object.values(world.actions ?? {}).find((action) => action.handler === 'drunk-transition') ?? DEFAULT_DRUNK_ACTION
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoverResolve(level: ResolveLevel, world: World): ResolveLevel {
|
||||||
|
const mechanic = resolveMechanic(world)
|
||||||
|
if (!mechanic.enabled || mechanic.safeRooms.recoverySteps === 0) return level
|
||||||
|
const idx = mechanic.ladder.indexOf(level)
|
||||||
|
if (idx <= 0) return level
|
||||||
|
return mechanic.ladder[Math.max(0, idx - mechanic.safeRooms.recoverySteps)] ?? level
|
||||||
|
}
|
||||||
|
|
||||||
|
function lightMessage(world: World, key: LightMechanicMessageKey, fallback: WorldMessageKey): string {
|
||||||
|
const mechanic = lightMechanic(world)
|
||||||
|
return mechanic?.messages?.[key] ?? message(world, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
export function initialStateFor(world: World): GameState {
|
export function initialStateFor(world: World): GameState {
|
||||||
const startingRoom = world.rooms[world.startingRoom]
|
const startingRoom = world.rooms[world.startingRoom]
|
||||||
@@ -32,13 +90,14 @@ export function initialStateFor(world: World): GameState {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const opening: TranscriptLine[] = [
|
const opening: TranscriptLine[] = [
|
||||||
{ kind: 'system', text: HALFSTREET_ASCII },
|
...(world.game?.openingArt ? [{ kind: 'system' as const, text: world.game.openingArt }] : []),
|
||||||
{ kind: 'system', text: startingRoom.title },
|
{ kind: 'system', text: startingRoom.title },
|
||||||
{ kind: 'narration', text: startingRoom.descriptions.firstVisit },
|
{ kind: 'narration', text: startingRoom.descriptions.firstVisit },
|
||||||
]
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
schemaVersion: SCHEMA_VERSION,
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
transcriptCap: world.game?.transcriptCap,
|
||||||
location: world.startingRoom,
|
location: world.startingRoom,
|
||||||
inventory,
|
inventory,
|
||||||
roomState: { [world.startingRoom]: { visited: true } },
|
roomState: { [world.startingRoom]: { visited: true } },
|
||||||
@@ -54,17 +113,20 @@ export function initialStateFor(world: World): GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getLightStatus(state: GameState, world: World): LightStatus | null {
|
export function getLightStatus(state: GameState, world: World): LightStatus | null {
|
||||||
|
const mechanic = lightMechanic(world)
|
||||||
|
if (!mechanic?.enabled || mechanic.ui?.meter === false) return null
|
||||||
|
|
||||||
let fallback: LightStatus | null = null
|
let fallback: LightStatus | null = null
|
||||||
for (const inst of state.inventory) {
|
for (const inst of state.inventory) {
|
||||||
const def = world.items[inst.id]
|
const def = world.items[inst.id]
|
||||||
if (!def?.lightable) continue
|
if (!def?.lightable) continue
|
||||||
const lit = inst.state['lit'] === true
|
const lit = inst.state[mechanic.stateKeys.lit] === true
|
||||||
const turnsLeft = lit ? getLightTurnsLeft(inst) : 0
|
const turnsLeft = lit ? getLightTurnsLeft(inst, world) : 0
|
||||||
const status = {
|
const status = {
|
||||||
itemId: inst.id,
|
itemId: inst.id,
|
||||||
lit,
|
lit,
|
||||||
turnsLeft,
|
turnsLeft,
|
||||||
maxTurns: LIGHT_TURNS_MAX,
|
maxTurns: mechanic.maxTurns,
|
||||||
}
|
}
|
||||||
if (lit) return status
|
if (lit) return status
|
||||||
fallback = fallback ?? status
|
fallback = fallback ?? status
|
||||||
@@ -74,7 +136,8 @@ export function getLightStatus(state: GameState, world: World): LightStatus | nu
|
|||||||
|
|
||||||
function append(state: GameState, lines: TranscriptLine[]): GameState {
|
function append(state: GameState, lines: TranscriptLine[]): GameState {
|
||||||
const transcript = [...state.transcript, ...lines]
|
const transcript = [...state.transcript, ...lines]
|
||||||
return { ...state, transcript: transcript.slice(-TRANSCRIPT_CAP) }
|
const cap = state.transcriptCap ?? TRANSCRIPT_CAP
|
||||||
|
return { ...state, transcript: transcript.slice(-cap) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getItemsInRoom(state: GameState, world: World, roomId: string): string[] {
|
export function getItemsInRoom(state: GameState, world: World, roomId: string): string[] {
|
||||||
@@ -94,11 +157,10 @@ function setRoomFlag(state: GameState, roomId: string, key: string, value: strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENDING_PRIORITY = ['mercy', 'true', 'replacement', 'bad', 'wrong'] as const
|
|
||||||
|
|
||||||
function evaluateEndings(state: GameState, world: World): GameState | null {
|
function evaluateEndings(state: GameState, world: World): GameState | null {
|
||||||
if (state.endedWith) return null
|
if (state.endedWith) return null
|
||||||
for (const id of ENDING_PRIORITY) {
|
const priority = world.endingPriority ?? Object.keys(world.endings)
|
||||||
|
for (const id of priority) {
|
||||||
const ending = world.endings[id]
|
const ending = world.endings[id]
|
||||||
if (!ending) continue
|
if (!ending) continue
|
||||||
const flags = ending.whenFlags
|
const flags = ending.whenFlags
|
||||||
@@ -145,11 +207,11 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World,
|
|||||||
if (command.kind === 'confirmation') {
|
if (command.kind === 'confirmation') {
|
||||||
const pending = state.pendingConfirmation
|
const pending = state.pendingConfirmation
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
return narrate(state, [{ kind: 'narration', text: 'Nothing to confirm.' }])
|
return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-to-confirm') }])
|
||||||
}
|
}
|
||||||
const cleared: GameState = { ...state, pendingConfirmation: null }
|
const cleared: GameState = { ...state, pendingConfirmation: null }
|
||||||
if (!command.confirmed) {
|
if (!command.confirmed) {
|
||||||
return narrate(cleared, [{ kind: 'narration', text: 'Cancelled.' }])
|
return narrate(cleared, [{ kind: 'narration', text: message(world, 'cancelled') }])
|
||||||
}
|
}
|
||||||
return dispatch(cleared, pending.command, world, true)
|
return dispatch(cleared, pending.command, world, true)
|
||||||
}
|
}
|
||||||
@@ -160,7 +222,7 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World,
|
|||||||
|
|
||||||
// Once the game has ended, only restart/undo (handled by the UI) can clear state.
|
// Once the game has ended, only restart/undo (handled by the UI) can clear state.
|
||||||
if (state.endedWith) {
|
if (state.endedWith) {
|
||||||
return narrate(state, [{ kind: 'narration', text: 'The story has ended. Type `restart` or `undo`.' }])
|
return narrate(state, [{ kind: 'narration', text: world.game?.endedText ?? 'The story has ended. Type `restart` or `undo`.' }])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirmed && isCriticalCommand(command)) {
|
if (!confirmed && isCriticalCommand(command)) {
|
||||||
@@ -173,7 +235,7 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World,
|
|||||||
if (command.kind === 'disambiguation') {
|
if (command.kind === 'disambiguation') {
|
||||||
const pending = state.pendingDisambiguation
|
const pending = state.pendingDisambiguation
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
return narrate(state, [{ kind: 'narration', text: 'Nothing to choose between.' }])
|
return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-to-choose') }])
|
||||||
}
|
}
|
||||||
const cleared: GameState = { ...state, pendingDisambiguation: null }
|
const cleared: GameState = { ...state, pendingDisambiguation: null }
|
||||||
return dispatch(
|
return dispatch(
|
||||||
@@ -186,9 +248,9 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World,
|
|||||||
|
|
||||||
if (command.kind === 'unknown') {
|
if (command.kind === 'unknown') {
|
||||||
const text =
|
const text =
|
||||||
command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.'
|
command.reason === 'unknown-verb' ? message(world, 'unknown-verb')
|
||||||
: command.reason === 'unknown-noun' ? 'You don\'t see anything like that here.'
|
: command.reason === 'unknown-noun' ? message(world, 'unknown-noun')
|
||||||
: 'You hesitate.'
|
: message(world, 'malformed')
|
||||||
return narrate(state, [{ kind: 'narration', text }])
|
return narrate(state, [{ kind: 'narration', text }])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +284,7 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World,
|
|||||||
if (command.verb === 'look') return withEndingCheck(handleLook(state, world), world)
|
if (command.verb === 'look') return withEndingCheck(handleLook(state, world), world)
|
||||||
if (command.verb === 'inventory') return withEndingCheck(handleInventory(state, world), world)
|
if (command.verb === 'inventory') return withEndingCheck(handleInventory(state, world), world)
|
||||||
if (command.verb === 'wait') return withEndingCheck(handleWait(state, world), world)
|
if (command.verb === 'wait') return withEndingCheck(handleWait(state, world), world)
|
||||||
if (command.verb === 'listen') return withEndingCheck(narrate(state, [{ kind: 'narration', text: 'You listen. The house listens back.' }]), world)
|
if (command.verb === 'listen') return withEndingCheck(narrate(state, [{ kind: 'narration', text: message(world, 'listen') }]), world)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.kind === 'verb-target') {
|
if (command.kind === 'verb-target') {
|
||||||
@@ -242,9 +304,9 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World,
|
|||||||
if (command.verb === 'use') {
|
if (command.verb === 'use') {
|
||||||
const target = world.items[command.target.canonical]
|
const target = world.items[command.target.canonical]
|
||||||
if (target?.lighter && !target.lightable) {
|
if (target?.lighter && !target.lightable) {
|
||||||
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: 'Use match with what?' }]), world)
|
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: message(world, 'use-lighter-with-what') }]), world)
|
||||||
}
|
}
|
||||||
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
|
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: message(world, 'use-unknown') }]), world)
|
||||||
}
|
}
|
||||||
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
|
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
|
||||||
}
|
}
|
||||||
@@ -260,16 +322,16 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World,
|
|||||||
return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world), world)
|
return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world), world)
|
||||||
}
|
}
|
||||||
if (command.verb === 'use') {
|
if (command.verb === 'use') {
|
||||||
const burnResult = handleBurnLetter(stateWithNoun, command.target.canonical, command.indirect.canonical, world)
|
const actionResult = handleDeclarativeAction(stateWithNoun, command, world)
|
||||||
if (burnResult) return withEndingCheck(burnResult, world)
|
if (actionResult) return withEndingCheck(actionResult, world)
|
||||||
const lightResult = handleUseAsLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world)
|
const lightResult = handleUseAsLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world)
|
||||||
if (lightResult) return withEndingCheck(lightResult, world)
|
if (lightResult) return withEndingCheck(lightResult, world)
|
||||||
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
|
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: message(world, 'use-unknown') }]), world)
|
||||||
}
|
}
|
||||||
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
|
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
|
||||||
}
|
}
|
||||||
|
|
||||||
return narrate(state, [{ kind: 'narration', text: 'Nothing happens.' }])
|
return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-happens') }])
|
||||||
}
|
}
|
||||||
|
|
||||||
function narrate(state: GameState, lines: TranscriptLine[]): DispatchResult {
|
function narrate(state: GameState, lines: TranscriptLine[]): DispatchResult {
|
||||||
@@ -291,11 +353,11 @@ function handleMeta(state: GameState, verb: 'restart' | 'undo' | 'hint' | 'save'
|
|||||||
|
|
||||||
function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd', world: World): DispatchResult {
|
function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd', world: World): DispatchResult {
|
||||||
const room = world.rooms[state.location]
|
const room = world.rooms[state.location]
|
||||||
if (!room) return narrate(state, [{ kind: 'narration', text: 'You are nowhere.' }])
|
if (!room) return narrate(state, [{ kind: 'narration', text: message(world, 'nowhere') }])
|
||||||
|
|
||||||
const dest = room.exits[direction]
|
const dest = room.exits[direction]
|
||||||
if (!dest) {
|
if (!dest) {
|
||||||
return narrate(state, [{ kind: 'narration', text: 'You can\'t go that way.' }])
|
return narrate(state, [{ kind: 'narration', text: message(world, 'no-exit') }])
|
||||||
}
|
}
|
||||||
|
|
||||||
const lock = room.lockedExits?.[direction]
|
const lock = room.lockedExits?.[direction]
|
||||||
@@ -307,7 +369,7 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destRoom = world.rooms[dest]
|
const destRoom = world.rooms[dest]
|
||||||
if (!destRoom) return narrate(state, [{ kind: 'narration', text: 'The way ahead is unfinished.' }])
|
if (!destRoom) return narrate(state, [{ kind: 'narration', text: message(world, 'unfinished-exit') }])
|
||||||
|
|
||||||
const visited = !!state.roomState[dest]?.['visited']
|
const visited = !!state.roomState[dest]?.['visited']
|
||||||
const description = visited ? destRoom.descriptions.revisit : destRoom.descriptions.firstVisit
|
const description = visited ? destRoom.descriptions.revisit : destRoom.descriptions.firstVisit
|
||||||
@@ -316,12 +378,10 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
|
|||||||
next = setRoomFlag(next, dest, 'visited', true)
|
next = setRoomFlag(next, dest, 'visited', true)
|
||||||
|
|
||||||
if (destRoom.safe) {
|
if (destRoom.safe) {
|
||||||
const ladder = ['steady', 'shaken', 'reeling', 'returning'] as const
|
next = { ...next, resolveLevel: recoverResolve(state.resolveLevel, world) }
|
||||||
const idx = ladder.indexOf(state.resolveLevel)
|
|
||||||
if (idx > 0) next = { ...next, resolveLevel: ladder[idx - 1]! }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const lightTick = advanceLightState(next, 1, world)
|
const lightTick = advanceLightState(next, 'move', world)
|
||||||
next = lightTick.state
|
next = lightTick.state
|
||||||
|
|
||||||
const arrivalLines: TranscriptLine[] = [
|
const arrivalLines: TranscriptLine[] = [
|
||||||
@@ -346,24 +406,33 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDrink(state: GameState, itemId: string, world: World): DispatchResult {
|
function handleDrink(state: GameState, itemId: string, world: World): DispatchResult {
|
||||||
if (itemId !== 'whiskey') {
|
const action = drunkAction(world)
|
||||||
return narrate(state, [{ kind: 'narration', text: "You can't drink that." }])
|
const targetItems = new Set([
|
||||||
|
...(action.requires?.allHeld ?? []),
|
||||||
|
...(action.requires?.allVisibleOrHeld ?? []),
|
||||||
|
...(action.consumes?.inventory ?? []),
|
||||||
|
])
|
||||||
|
if (!action.verbs.includes('drink') || !targetItems.has(itemId)) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: message(world, 'cannot-drink') }])
|
||||||
}
|
}
|
||||||
const held = state.inventory.some((i) => i.id === 'whiskey')
|
const requiredHeld = action.requires?.allHeld ?? [...targetItems]
|
||||||
|
const held = requiredHeld.every((requiredId) => state.inventory.some((i) => i.id === requiredId))
|
||||||
if (!held) {
|
if (!held) {
|
||||||
return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }])
|
return narrate(state, [{ kind: 'narration', text: action.messages.missingRequired ?? message(world, 'need-carrying') }])
|
||||||
}
|
}
|
||||||
const dest = world.rooms['drunk-hall']
|
const config = action.drunkTransition ?? DEFAULT_DRUNK_ACTION.drunkTransition!
|
||||||
|
const consumed = new Set(action.consumes?.inventory ?? [itemId])
|
||||||
|
const dest = world.rooms[config.destinationRoom]
|
||||||
const next: GameState = {
|
const next: GameState = {
|
||||||
...state,
|
...state,
|
||||||
location: 'drunk-hall',
|
location: config.destinationRoom,
|
||||||
inventory: state.inventory.filter((i) => i.id !== 'whiskey'),
|
inventory: state.inventory.filter((i) => !consumed.has(i.id)),
|
||||||
flags: { ...state.flags, drunk: true, drunkMoves: 0, drunkSecretFound: false },
|
flags: { ...state.flags, drunk: true, drunkMoves: 0, drunkSecretFound: false },
|
||||||
}
|
}
|
||||||
const visited = !!next.roomState['drunk-hall']?.['visited']
|
const visited = !!next.roomState[config.destinationRoom]?.['visited']
|
||||||
const withVisit = setRoomFlag(next, 'drunk-hall', 'visited', true)
|
const withVisit = setRoomFlag(next, config.destinationRoom, 'visited', true)
|
||||||
const lines: TranscriptLine[] = [
|
const lines: TranscriptLine[] = [
|
||||||
{ kind: 'narration', text: 'You drink from the bottle. It tastes of smoke, sugar, and rainwater left too long in a pipe.' },
|
{ kind: 'narration', text: action.messages.success },
|
||||||
]
|
]
|
||||||
if (dest) {
|
if (dest) {
|
||||||
lines.push(
|
lines.push(
|
||||||
@@ -377,60 +446,74 @@ function handleDrink(state: GameState, itemId: string, world: World): DispatchRe
|
|||||||
function maybeResolveDrunkState(result: DispatchResult, world: World): DispatchResult {
|
function maybeResolveDrunkState(result: DispatchResult, world: World): DispatchResult {
|
||||||
if (result.state.flags['drunk'] !== true) return result
|
if (result.state.flags['drunk'] !== true) return result
|
||||||
if (result.state.flags['drunkSecretFound'] === true) {
|
if (result.state.flags['drunkSecretFound'] === true) {
|
||||||
const passed = passOutFromDrunk(result.state, world, 'The faceless man steps backward into the dark. The floor rises under you, or you fall toward it.')
|
const action = drunkAction(world)
|
||||||
|
const passed = passOutFromDrunk(
|
||||||
|
result.state,
|
||||||
|
world,
|
||||||
|
action.messages.secretFoundPassOut ?? DEFAULT_DRUNK_ACTION.messages.secretFoundPassOut!,
|
||||||
|
)
|
||||||
return { state: passed.state, appended: [...result.appended, ...passed.appended] }
|
return { state: passed.state, appended: [...result.appended, ...passed.appended] }
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function advanceDrunkTurns(state: GameState, world: World): DispatchResult {
|
function advanceDrunkTurns(state: GameState, world: World): DispatchResult {
|
||||||
|
const action = drunkAction(world)
|
||||||
|
const config = action.drunkTransition ?? DEFAULT_DRUNK_ACTION.drunkTransition!
|
||||||
const current = typeof state.flags['drunkMoves'] === 'number' ? state.flags['drunkMoves'] : 0
|
const current = typeof state.flags['drunkMoves'] === 'number' ? state.flags['drunkMoves'] : 0
|
||||||
const moves = current + 1
|
const moves = current + 1
|
||||||
const next = { ...state, flags: { ...state.flags, drunkMoves: moves } }
|
const next = { ...state, flags: { ...state.flags, drunkMoves: moves } }
|
||||||
if (moves < DRUNK_TURNS_MAX) return { state: next, appended: [] }
|
if (moves < config.maxMoves) return { state: next, appended: [] }
|
||||||
return passOutFromDrunk(next, world, 'The rooms keep turning until they become one room. Then even that room is gone.')
|
return passOutFromDrunk(
|
||||||
|
next,
|
||||||
|
world,
|
||||||
|
action.messages.tooManyMovesPassOut ?? DEFAULT_DRUNK_ACTION.messages.tooManyMovesPassOut!,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function passOutFromDrunk(state: GameState, world: World, preface: string): DispatchResult {
|
function passOutFromDrunk(state: GameState, world: World, preface: string): DispatchResult {
|
||||||
const foyer = world.rooms['foyer']
|
const action = drunkAction(world)
|
||||||
const kitchenState = state.roomState['kitchen'] ?? {}
|
const config = action.drunkTransition ?? DEFAULT_DRUNK_ACTION.drunkTransition!
|
||||||
const kitchenTaken = ((kitchenState['takenItems'] ?? []) as string[]).filter((id) => id !== 'whiskey')
|
const resetItem = action.consumes?.inventory?.[0] ?? 'whiskey'
|
||||||
const kitchenDropped = ((kitchenState['droppedItems'] ?? []) as string[]).filter((id) => id !== 'whiskey')
|
const wakeRoom = world.rooms[config.wakeRoom]
|
||||||
|
const resetRoomState = state.roomState[config.resetRoom] ?? {}
|
||||||
|
const resetTaken = ((resetRoomState['takenItems'] ?? []) as string[]).filter((id) => id !== resetItem)
|
||||||
|
const resetDropped = ((resetRoomState['droppedItems'] ?? []) as string[]).filter((id) => id !== resetItem)
|
||||||
const next: GameState = {
|
const next: GameState = {
|
||||||
...state,
|
...state,
|
||||||
location: 'foyer',
|
location: config.wakeRoom,
|
||||||
inventory: state.inventory.filter((i) => i.id !== 'whiskey'),
|
inventory: state.inventory.filter((i) => i.id !== resetItem),
|
||||||
flags: { ...state.flags, drunk: false, drunkMoves: 0, drunkSecretFound: false },
|
flags: { ...state.flags, drunk: false, drunkMoves: 0, drunkSecretFound: false },
|
||||||
roomState: {
|
roomState: {
|
||||||
...state.roomState,
|
...state.roomState,
|
||||||
kitchen: {
|
[config.resetRoom]: {
|
||||||
...kitchenState,
|
...resetRoomState,
|
||||||
takenItems: kitchenTaken,
|
takenItems: resetTaken,
|
||||||
droppedItems: kitchenDropped,
|
droppedItems: resetDropped,
|
||||||
},
|
},
|
||||||
foyer: { ...(state.roomState['foyer'] ?? {}), visited: true },
|
[config.wakeRoom]: { ...(state.roomState[config.wakeRoom] ?? {}), visited: true },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const lines: TranscriptLine[] = [
|
const lines: TranscriptLine[] = [
|
||||||
{ kind: 'narration', text: preface },
|
{ kind: 'narration', text: preface },
|
||||||
{ kind: 'system', text: foyer?.title ?? '[ Foyer ]' },
|
{ kind: 'system', text: wakeRoom?.title ?? `[ ${config.wakeRoom} ]` },
|
||||||
{ kind: 'narration', text: foyer?.descriptions.revisit ?? 'You wake in the foyer.' },
|
{ kind: 'narration', text: wakeRoom?.descriptions.revisit ?? `You wake in ${config.wakeRoom}.` },
|
||||||
{ kind: 'narration', text: 'The bottle is not with you. Somewhere in the kitchen, it is half full again.' },
|
{ kind: 'narration', text: action.messages.reset ?? DEFAULT_DRUNK_ACTION.messages.reset! },
|
||||||
]
|
]
|
||||||
return narrate(next, lines)
|
return narrate(next, lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWait(state: GameState, world: World): DispatchResult {
|
function handleWait(state: GameState, world: World): DispatchResult {
|
||||||
const lightTick = advanceLightState(state, 1, world)
|
const lightTick = advanceLightState(state, 'wait', world)
|
||||||
return narrate(lightTick.state, [
|
return narrate(lightTick.state, [
|
||||||
{ kind: 'narration', text: 'Time passes.' },
|
{ kind: 'narration', text: message(world, 'time-passes') },
|
||||||
...lightTick.lines,
|
...lightTick.lines,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLook(state: GameState, world: World): DispatchResult {
|
function handleLook(state: GameState, world: World): DispatchResult {
|
||||||
const room = world.rooms[state.location]
|
const room = world.rooms[state.location]
|
||||||
if (!room) return narrate(state, [{ kind: 'narration', text: 'You see nothing.' }])
|
if (!room) return narrate(state, [{ kind: 'narration', text: message(world, 'see-nothing') }])
|
||||||
const items = getItemsInRoom(state, world, state.location)
|
const items = getItemsInRoom(state, world, state.location)
|
||||||
const itemNarration = describeRoomItems(items.map((id) => world.items[id]?.short ?? id))
|
const itemNarration = describeRoomItems(items.map((id) => world.items[id]?.short ?? id))
|
||||||
return narrate(state, [
|
return narrate(state, [
|
||||||
@@ -459,30 +542,31 @@ function joinList(values: string[]): string {
|
|||||||
|
|
||||||
function handleInventory(state: GameState, world: World): DispatchResult {
|
function handleInventory(state: GameState, world: World): DispatchResult {
|
||||||
if (state.inventory.length === 0) {
|
if (state.inventory.length === 0) {
|
||||||
return narrate(state, [{ kind: 'narration', text: 'You are empty-handed.' }])
|
return narrate(state, [{ kind: 'narration', text: message(world, 'inventory-empty') }])
|
||||||
}
|
}
|
||||||
const lines = state.inventory.map((inst) => {
|
const lines = state.inventory.map((inst) => {
|
||||||
const item = world.items[inst.id]
|
const item = world.items[inst.id]
|
||||||
const litSuffix = inst.state['lit'] === true ? ' (lit)' : ''
|
const mechanic = lightMechanic(world)
|
||||||
|
const litSuffix = mechanic?.enabled && inst.state[mechanic.stateKeys.lit] === true ? ' (lit)' : ''
|
||||||
return ` ${item?.short ?? inst.id}${litSuffix}`
|
return ` ${item?.short ?? inst.id}${litSuffix}`
|
||||||
})
|
})
|
||||||
return narrate(state, [
|
return narrate(state, [
|
||||||
{ kind: 'narration', text: 'You are carrying:' },
|
{ kind: 'narration', text: message(world, 'inventory-heading') },
|
||||||
{ kind: 'narration', text: lines.join('\n') },
|
{ kind: 'narration', text: lines.join('\n') },
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTake(state: GameState, itemId: string, world: World): DispatchResult {
|
function handleTake(state: GameState, itemId: string, world: World): DispatchResult {
|
||||||
const item = world.items[itemId]
|
const item = world.items[itemId]
|
||||||
if (!item) return narrate(state, [{ kind: 'narration', text: 'You don\'t see that here.' }])
|
if (!item) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-here') }])
|
||||||
if (!item.takeable) return narrate(state, [{ kind: 'narration', text: 'You can\'t take that.' }])
|
if (!item.takeable) return narrate(state, [{ kind: 'narration', text: message(world, 'cannot-take') }])
|
||||||
|
|
||||||
const itemsHere = getItemsInRoom(state, world, state.location)
|
const itemsHere = getItemsInRoom(state, world, state.location)
|
||||||
if (!itemsHere.includes(itemId)) {
|
if (!itemsHere.includes(itemId)) {
|
||||||
return narrate(state, [{ kind: 'narration', text: 'You don\'t see that here.' }])
|
return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-here') }])
|
||||||
}
|
}
|
||||||
if (state.inventory.find((i) => i.id === itemId)) {
|
if (state.inventory.find((i) => i.id === itemId)) {
|
||||||
return narrate(state, [{ kind: 'narration', text: 'You already have it.' }])
|
return narrate(state, [{ kind: 'narration', text: message(world, 'already-have') }])
|
||||||
}
|
}
|
||||||
|
|
||||||
const wasInRoomBase = (world.rooms[state.location]?.items ?? []).includes(itemId)
|
const wasInRoomBase = (world.rooms[state.location]?.items ?? []).includes(itemId)
|
||||||
@@ -497,17 +581,18 @@ function handleTake(state: GameState, itemId: string, world: World): DispatchRes
|
|||||||
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
|
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
|
||||||
next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== itemId))
|
next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== itemId))
|
||||||
}
|
}
|
||||||
return narrate(next, [{ kind: 'narration', text: 'Taken.' }])
|
return narrate(next, [{ kind: 'narration', text: message(world, 'taken') }])
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDrop(state: GameState, itemId: string, _world: World): DispatchResult {
|
function handleDrop(state: GameState, itemId: string, world: World): DispatchResult {
|
||||||
if (!state.inventory.find((i) => i.id === itemId)) {
|
if (!state.inventory.find((i) => i.id === itemId)) {
|
||||||
return narrate(state, [{ kind: 'narration', text: 'You don\'t have that.' }])
|
return narrate(state, [{ kind: 'narration', text: message(world, 'dont-have') }])
|
||||||
}
|
}
|
||||||
const itemDef = _world.items[itemId]
|
const itemDef = world.items[itemId]
|
||||||
const itemInst = state.inventory.find((i) => i.id === itemId) ?? null
|
const itemInst = state.inventory.find((i) => i.id === itemId) ?? null
|
||||||
if (itemDef?.lightable && itemInst?.state['lit'] === true) {
|
const mechanic = lightMechanic(world)
|
||||||
return narrate(state, [{ kind: 'narration', text: "Extinguish it first." }])
|
if (mechanic?.enabled && itemDef?.lightable && itemInst?.state[mechanic.stateKeys.lit] === true) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'dropLit', 'drop-lit') }])
|
||||||
}
|
}
|
||||||
let next: GameState = {
|
let next: GameState = {
|
||||||
...state,
|
...state,
|
||||||
@@ -515,17 +600,17 @@ function handleDrop(state: GameState, itemId: string, _world: World): DispatchRe
|
|||||||
}
|
}
|
||||||
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
|
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
|
||||||
next = setRoomFlag(next, state.location, 'droppedItems', [...dropped, itemId])
|
next = setRoomFlag(next, state.location, 'droppedItems', [...dropped, itemId])
|
||||||
return narrate(next, [{ kind: 'narration', text: 'Dropped.' }])
|
return narrate(next, [{ kind: 'narration', text: message(world, 'dropped') }])
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleExamine(state: GameState, itemId: string, world: World): DispatchResult {
|
function handleExamine(state: GameState, itemId: string, world: World): DispatchResult {
|
||||||
const item = world.items[itemId]
|
const item = world.items[itemId]
|
||||||
if (!item) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }])
|
if (!item) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
|
||||||
const inventoryInst = state.inventory.find((i) => i.id === itemId) ?? null
|
const inventoryInst = state.inventory.find((i) => i.id === itemId) ?? null
|
||||||
const visible =
|
const visible =
|
||||||
inventoryInst ||
|
inventoryInst ||
|
||||||
getItemsInRoom(state, world, state.location).includes(itemId)
|
getItemsInRoom(state, world, state.location).includes(itemId)
|
||||||
if (!visible) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }])
|
if (!visible) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
|
||||||
return narrate(state, [{ kind: 'narration', text: describeItem(itemId, item.long, inventoryInst) }])
|
return narrate(state, [{ kind: 'narration', text: describeItem(itemId, item.long, inventoryInst) }])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,46 +629,49 @@ function spellSmallCount(value: number): string {
|
|||||||
|
|
||||||
function handleRead(state: GameState, itemId: string, world: World): DispatchResult {
|
function handleRead(state: GameState, itemId: string, world: World): DispatchResult {
|
||||||
const item = world.items[itemId]
|
const item = world.items[itemId]
|
||||||
if (!item) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
|
if (!item) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
|
||||||
const visible =
|
const visible =
|
||||||
state.inventory.find((i) => i.id === itemId) ||
|
state.inventory.find((i) => i.id === itemId) ||
|
||||||
getItemsInRoom(state, world, state.location).includes(itemId)
|
getItemsInRoom(state, world, state.location).includes(itemId)
|
||||||
if (!visible) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
|
if (!visible) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
|
||||||
if (!item.readable || !item.readableText) {
|
if (!item.readable || !item.readableText) {
|
||||||
return narrate(state, [{ kind: 'narration', text: "There's nothing to read on it." }])
|
return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-to-read') }])
|
||||||
}
|
}
|
||||||
return narrate(state, [{ kind: 'narration', text: item.readableText }])
|
return narrate(state, [{ kind: 'narration', text: item.readableText }])
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLight(state: GameState, targetId: string, instrumentId: string | null, world: World): DispatchResult {
|
function handleLight(state: GameState, targetId: string, instrumentId: string | null, world: World): DispatchResult {
|
||||||
|
const mechanic = lightMechanic(world)
|
||||||
|
if (!mechanic?.enabled) return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-happens') }])
|
||||||
|
|
||||||
const target = world.items[targetId]
|
const target = world.items[targetId]
|
||||||
if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
|
if (!target) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
|
||||||
if (target.lighter && !target.lightable) return narrate(state, [{ kind: 'narration', text: 'Use match with what?' }])
|
if (target.lighter && !target.lightable) return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'useLighterWithWhat', 'use-lighter-with-what') }])
|
||||||
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't light that." }])
|
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'cannotLight', 'cannot-light') }])
|
||||||
const targetInst = state.inventory.find((i) => i.id === targetId) ?? null
|
const targetInst = state.inventory.find((i) => i.id === targetId) ?? null
|
||||||
const visibleInRoom = getItemsInRoom(state, world, state.location).includes(targetId)
|
const visibleInRoom = getItemsInRoom(state, world, state.location).includes(targetId)
|
||||||
if (!targetInst && !visibleInRoom) {
|
if (!targetInst && !visibleInRoom) {
|
||||||
return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
|
return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
|
||||||
}
|
}
|
||||||
// The 'lit' state lives on the inventory instance for inventory items, or
|
// The 'lit' state lives on the inventory instance for inventory items, or
|
||||||
// (eventually) on roomState for items left in a room. For now we only
|
// (eventually) on roomState for items left in a room. For now we only
|
||||||
// support lighting items the player is carrying.
|
// support lighting items the player is carrying.
|
||||||
if (!targetInst) {
|
if (!targetInst) {
|
||||||
return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }])
|
return narrate(state, [{ kind: 'narration', text: message(world, 'need-carrying') }])
|
||||||
}
|
}
|
||||||
if (targetInst.state['lit'] === true) {
|
if (targetInst.state[mechanic.stateKeys.lit] === true) {
|
||||||
return narrate(state, [{ kind: 'narration', text: "It's already lit." }])
|
return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'alreadyLit', 'already-lit') }])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick an instrument. If explicit, validate it; if implicit, find any.
|
// Pick an instrument. If explicit, validate it; if implicit, find any.
|
||||||
let lighterInst = null as typeof state.inventory[number] | null
|
let lighterInst = null as typeof state.inventory[number] | null
|
||||||
if (instrumentId) {
|
if (instrumentId) {
|
||||||
lighterInst = state.inventory.find((i) => i.id === instrumentId) ?? null
|
lighterInst = state.inventory.find((i) => i.id === instrumentId) ?? null
|
||||||
if (!lighterInst) return narrate(state, [{ kind: 'narration', text: "You don't have that." }])
|
if (!lighterInst) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-have') }])
|
||||||
const lighterDef = world.items[instrumentId]
|
const lighterDef = world.items[instrumentId]
|
||||||
if (!lighterDef?.lighter) return narrate(state, [{ kind: 'narration', text: "That isn't going to help." }])
|
if (!lighterDef?.lighter) return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'notHelpful', 'not-helpful') }])
|
||||||
if (typeof lighterInst.state['uses'] === 'number' && lighterInst.state['uses'] <= 0) {
|
if (typeof lighterInst.state['uses'] === 'number' && lighterInst.state['uses'] <= 0) {
|
||||||
return narrate(state, [{ kind: 'narration', text: "It is spent." }])
|
return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'spent', 'spent') }])
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (const inst of state.inventory) {
|
for (const inst of state.inventory) {
|
||||||
@@ -594,7 +682,7 @@ function handleLight(state: GameState, targetId: string, instrumentId: string |
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (!lighterInst) {
|
if (!lighterInst) {
|
||||||
return narrate(state, [{ kind: 'narration', text: 'You have nothing to light it with.' }])
|
return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'noLighter', 'no-lighter') }])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,64 +691,105 @@ function handleLight(state: GameState, targetId: string, instrumentId: string |
|
|||||||
const lighterUsesField = typeof lighterInst.state['uses'] === 'number' ? lighterInst.state['uses'] : null
|
const lighterUsesField = typeof lighterInst.state['uses'] === 'number' ? lighterInst.state['uses'] : null
|
||||||
const newLighterUses = lighterUsesField === null ? null : lighterUsesField - 1
|
const newLighterUses = lighterUsesField === null ? null : lighterUsesField - 1
|
||||||
const newInventory = state.inventory.map((i) => {
|
const newInventory = state.inventory.map((i) => {
|
||||||
if (i.id === targetInst.id) return { ...i, state: { ...i.state, lit: true, burn: LIGHT_TURNS_MAX } }
|
if (i.id === targetInst.id) return { ...i, state: { ...i.state, [mechanic.stateKeys.lit]: true, [mechanic.stateKeys.burn]: mechanic.maxTurns } }
|
||||||
if (i.id === lighterInst!.id && newLighterUses !== null) return { ...i, state: { ...i.state, uses: newLighterUses } }
|
if (i.id === lighterInst!.id && newLighterUses !== null) return { ...i, state: { ...i.state, uses: newLighterUses } }
|
||||||
return i
|
return i
|
||||||
})
|
})
|
||||||
const lines: TranscriptLine[] = [{ kind: 'narration', text: target.litText ?? 'It catches.' }]
|
const lines: TranscriptLine[] = [{ kind: 'narration', text: target.litText ?? lightMessage(world, 'flameCatches', 'flame-catches') }]
|
||||||
if (newLighterUses === 0) {
|
if (newLighterUses === 0) {
|
||||||
lines.push({ kind: 'narration', text: lighterDef.lighterEmptyText ?? 'It is spent.' })
|
lines.push({ kind: 'narration', text: lighterDef.lighterEmptyText ?? lightMessage(world, 'spent', 'spent') })
|
||||||
}
|
}
|
||||||
return narrate({ ...state, inventory: newInventory }, lines)
|
return narrate({ ...state, inventory: newInventory }, lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBurnLetter(state: GameState, firstId: string, secondId: string, world: World): DispatchResult | null {
|
function handleDeclarativeAction(
|
||||||
const ids = [firstId, secondId]
|
state: GameState,
|
||||||
if (!ids.includes('letter') || !ids.includes('matches')) return null
|
command: Extract<ParsedCommand, { kind: 'verb-target-prep' }>,
|
||||||
|
world: World,
|
||||||
|
): DispatchResult | null {
|
||||||
|
const action = findDeclarativeAction(command, world)
|
||||||
|
if (!action) return null
|
||||||
|
|
||||||
const matches = state.inventory.find((i) => i.id === 'matches')
|
for (const itemId of action.requires?.allVisibleOrHeld ?? []) {
|
||||||
if (!matches) return narrate(state, [{ kind: 'narration', text: "You don't have a match." }])
|
if (!isVisibleOrHeld(state, world, itemId)) {
|
||||||
if (typeof matches.state['uses'] === 'number' && matches.state['uses'] <= 0) {
|
return narrate(state, [{ kind: 'narration', text: action.messages.missingRequired ?? message(world, 'dont-see-anything') }])
|
||||||
return narrate(state, [{ kind: 'narration', text: 'The matchbook is empty.' }])
|
}
|
||||||
|
}
|
||||||
|
for (const itemId of action.requires?.allHeld ?? []) {
|
||||||
|
if (!state.inventory.some((i) => i.id === itemId)) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: action.messages.missingRequired ?? message(world, 'dont-have') }])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const letterHeld = state.inventory.some((i) => i.id === 'letter')
|
const decrement = action.decrements
|
||||||
const letterInRoom = getItemsInRoom(state, world, state.location).includes('letter')
|
const decremented = decrement ? state.inventory.find((i) => i.id === decrement.item) : null
|
||||||
if (!letterHeld && !letterInRoom) {
|
if (decrement && !decremented) {
|
||||||
return narrate(state, [{ kind: 'narration', text: "You don't see the letter here." }])
|
return narrate(state, [{ kind: 'narration', text: action.messages.missingRequired ?? message(world, 'dont-have') }])
|
||||||
|
}
|
||||||
|
const decrementStateValue = decrement ? decremented?.state[decrement.stateKey] : null
|
||||||
|
const decrementedValue: number | null = typeof decrementStateValue === 'number' ? decrementStateValue : null
|
||||||
|
if (decrementedValue !== null && decrementedValue <= 0) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: action.messages.spent ?? message(world, 'spent') }])
|
||||||
}
|
}
|
||||||
|
|
||||||
const newMatchesUses = typeof matches.state['uses'] === 'number' ? matches.state['uses'] - 1 : null
|
const consumed = new Set(action.consumes?.inventory ?? [])
|
||||||
let next: GameState = {
|
let next: GameState = {
|
||||||
...state,
|
...state,
|
||||||
inventory: state.inventory
|
inventory: state.inventory
|
||||||
.filter((i) => i.id !== 'letter')
|
.filter((i) => !consumed.has(i.id))
|
||||||
.map((i) => i.id === 'matches' && newMatchesUses !== null ? { ...i, state: { ...i.state, uses: newMatchesUses } } : i),
|
.map((i) =>
|
||||||
flags: { ...state.flags, letterBurned: true },
|
decrement && i.id === decrement.item && decrementedValue !== null
|
||||||
|
? { ...i, state: { ...i.state, [decrement.stateKey]: decrementedValue - 1 } }
|
||||||
|
: i,
|
||||||
|
),
|
||||||
|
flags: { ...state.flags, ...(action.setsFlags ?? {}) },
|
||||||
}
|
}
|
||||||
|
|
||||||
if (letterInRoom) {
|
for (const itemId of consumed) {
|
||||||
const baseItems = world.rooms[state.location]?.items ?? []
|
next = removeVisibleRoomItem(next, world, itemId)
|
||||||
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
|
|
||||||
if (baseItems.includes('letter')) {
|
|
||||||
const taken = (next.roomState[state.location]?.['takenItems'] ?? []) as string[]
|
|
||||||
next = setRoomFlag(next, state.location, 'takenItems', [...new Set([...taken, 'letter'])])
|
|
||||||
}
|
|
||||||
if (dropped.includes('letter')) {
|
|
||||||
next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== 'letter'))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines: TranscriptLine[] = [
|
const lines: TranscriptLine[] = [{ kind: 'narration', text: action.messages.success }]
|
||||||
{ kind: 'narration', text: 'The letter catches at one corner. In a few breaths it is ash.' },
|
if (decrementedValue === 1 && action.messages.spent) {
|
||||||
]
|
lines.push({ kind: 'narration', text: action.messages.spent })
|
||||||
if (newMatchesUses === 0) {
|
|
||||||
lines.push({ kind: 'narration', text: world.items['matches']?.lighterEmptyText ?? 'The matchbook is empty.' })
|
|
||||||
}
|
}
|
||||||
return narrate(next, lines)
|
return narrate(next, lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findDeclarativeAction(
|
||||||
|
command: Extract<ParsedCommand, { kind: 'verb-target-prep' }>,
|
||||||
|
world: World,
|
||||||
|
): DeclarativeAction | null {
|
||||||
|
const commandItems = new Set([command.target.canonical, command.indirect.canonical])
|
||||||
|
for (const action of Object.values(world.actions ?? {})) {
|
||||||
|
if (!action.verbs.includes(command.verb)) continue
|
||||||
|
const required = [...(action.requires?.allVisibleOrHeld ?? []), ...(action.requires?.allHeld ?? [])]
|
||||||
|
if (required.length > 0 && required.every((itemId) => commandItems.has(itemId))) return action
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVisibleOrHeld(state: GameState, world: World, itemId: string): boolean {
|
||||||
|
return state.inventory.some((i) => i.id === itemId) || getItemsInRoom(state, world, state.location).includes(itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeVisibleRoomItem(state: GameState, world: World, itemId: string): GameState {
|
||||||
|
if (!getItemsInRoom(state, world, state.location).includes(itemId)) return state
|
||||||
|
const baseItems = world.rooms[state.location]?.items ?? []
|
||||||
|
let next = state
|
||||||
|
if (baseItems.includes(itemId)) {
|
||||||
|
const taken = (next.roomState[state.location]?.['takenItems'] ?? []) as string[]
|
||||||
|
next = setRoomFlag(next, state.location, 'takenItems', [...new Set([...taken, itemId])])
|
||||||
|
}
|
||||||
|
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
|
||||||
|
if (dropped.includes(itemId)) {
|
||||||
|
next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== itemId))
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
function handleUseAsLight(state: GameState, firstId: string, secondId: string, world: World): DispatchResult | null {
|
function handleUseAsLight(state: GameState, firstId: string, secondId: string, world: World): DispatchResult | null {
|
||||||
|
if (!lightMechanic(world)?.enabled) return null
|
||||||
const first = world.items[firstId]
|
const first = world.items[firstId]
|
||||||
const second = world.items[secondId]
|
const second = world.items[secondId]
|
||||||
if (first?.lighter && second?.lightable) return handleLight(state, secondId, firstId, world)
|
if (first?.lighter && second?.lightable) return handleLight(state, secondId, firstId, world)
|
||||||
@@ -669,45 +798,50 @@ function handleUseAsLight(state: GameState, firstId: string, secondId: string, w
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleExtinguish(state: GameState, targetId: string, world: World): DispatchResult {
|
function handleExtinguish(state: GameState, targetId: string, world: World): DispatchResult {
|
||||||
|
const mechanic = lightMechanic(world)
|
||||||
|
if (!mechanic?.enabled) return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-happens') }])
|
||||||
|
|
||||||
const target = world.items[targetId]
|
const target = world.items[targetId]
|
||||||
if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
|
if (!target) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }])
|
||||||
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't extinguish that." }])
|
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'cannotExtinguish', 'cannot-extinguish') }])
|
||||||
const targetInst = state.inventory.find((i) => i.id === targetId)
|
const targetInst = state.inventory.find((i) => i.id === targetId)
|
||||||
if (!targetInst) return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }])
|
if (!targetInst) return narrate(state, [{ kind: 'narration', text: message(world, 'need-carrying') }])
|
||||||
if (targetInst.state['lit'] !== true) {
|
if (targetInst.state[mechanic.stateKeys.lit] !== true) {
|
||||||
return narrate(state, [{ kind: 'narration', text: "It isn't lit." }])
|
return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'notLit', 'not-lit') }])
|
||||||
}
|
}
|
||||||
const newInventory = state.inventory.map((i) =>
|
const newInventory = state.inventory.map((i) =>
|
||||||
i.id === targetId ? { ...i, state: { ...i.state, lit: false, burn: 0 } } : i,
|
i.id === targetId ? { ...i, state: { ...i.state, [mechanic.stateKeys.lit]: false, [mechanic.stateKeys.burn]: 0 } } : i,
|
||||||
)
|
)
|
||||||
return narrate({ ...state, inventory: newInventory }, [{ kind: 'narration', text: target.extinguishedText ?? 'The flame dies.' }])
|
return narrate({ ...state, inventory: newInventory }, [{ kind: 'narration', text: target.extinguishedText ?? lightMessage(world, 'flameDies', 'flame-dies') }])
|
||||||
}
|
}
|
||||||
|
|
||||||
function advanceLightState(state: GameState, cost: number, world: World): { state: GameState; lines: TranscriptLine[] } {
|
function advanceLightState(state: GameState, trigger: 'move' | 'wait', world: World): { state: GameState; lines: TranscriptLine[] } {
|
||||||
if (cost <= 0) return { state, lines: [] }
|
const mechanic = lightMechanic(world)
|
||||||
|
if (!mechanic?.enabled || !mechanic.burnOn.includes(trigger)) return { state, lines: [] }
|
||||||
|
|
||||||
let changed = false
|
let changed = false
|
||||||
const lines: TranscriptLine[] = []
|
const lines: TranscriptLine[] = []
|
||||||
const inventory = state.inventory.map((inst) => {
|
const inventory = state.inventory.map((inst) => {
|
||||||
const def = world.items[inst.id]
|
const def = world.items[inst.id]
|
||||||
if (!def?.lightable || inst.state['lit'] !== true) return inst
|
if (!def?.lightable || inst.state[mechanic.stateKeys.lit] !== true) return inst
|
||||||
|
|
||||||
const turnsLeft = getLightTurnsLeft(inst)
|
const turnsLeft = getLightTurnsLeft(inst, world)
|
||||||
const nextTurns = Math.max(0, turnsLeft - cost)
|
const nextTurns = Math.max(0, turnsLeft - 1)
|
||||||
changed = true
|
changed = true
|
||||||
|
|
||||||
if (nextTurns === 0) {
|
if (nextTurns === 0) {
|
||||||
lines.push({ kind: 'narration', text: def.extinguishedText ?? 'The flame dies.' })
|
lines.push({ kind: 'narration', text: def.extinguishedText ?? lightMessage(world, 'flameDies', 'flame-dies') })
|
||||||
return { ...inst, state: { ...inst.state, lit: false, burn: 0 } }
|
return { ...inst, state: { ...inst.state, [mechanic.stateKeys.lit]: false, [mechanic.stateKeys.burn]: 0 } }
|
||||||
}
|
}
|
||||||
return { ...inst, state: { ...inst.state, burn: nextTurns } }
|
return { ...inst, state: { ...inst.state, [mechanic.stateKeys.burn]: nextTurns } }
|
||||||
})
|
})
|
||||||
|
|
||||||
return changed ? { state: { ...state, inventory }, lines } : { state, lines }
|
return changed ? { state: { ...state, inventory }, lines } : { state, lines }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLightTurnsLeft(inst: ItemInstance): number {
|
function getLightTurnsLeft(inst: ItemInstance, world: World): number {
|
||||||
const turns = inst.state['burn']
|
const mechanic = lightMechanic(world)
|
||||||
|
const turns = inst.state[mechanic.stateKeys.burn]
|
||||||
if (typeof turns === 'number') return Math.max(0, turns)
|
if (typeof turns === 'number') return Math.max(0, turns)
|
||||||
return inst.state['lit'] === true ? LIGHT_TURNS_MAX : 0
|
return inst.state[mechanic.stateKeys.lit] === true ? mechanic.maxTurns : 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,23 @@ const world: World = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withResolveMechanic(overrides: Partial<NonNullable<NonNullable<World['mechanics']>['resolve']>>): World {
|
||||||
|
return {
|
||||||
|
...world,
|
||||||
|
mechanics: {
|
||||||
|
resolve: {
|
||||||
|
enabled: true,
|
||||||
|
handler: 'resolve',
|
||||||
|
ladder: ['steady', 'shaken', 'reeling', 'returning'],
|
||||||
|
wrongVerbCost: 1,
|
||||||
|
safeRooms: { recoverySteps: 1 },
|
||||||
|
failure: { retreatAt: 'returning', afterRetreat: 'shaken' },
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('encounters — phase advancement', () => {
|
describe('encounters — phase advancement', () => {
|
||||||
it('triggers an encounter on entering its room', () => {
|
it('triggers an encounter on entering its room', () => {
|
||||||
let s = initialStateFor(world)
|
let s = initialStateFor(world)
|
||||||
@@ -157,6 +174,34 @@ describe('encounters — phase advancement', () => {
|
|||||||
expect(s.resolveLevel).toBe('steady')
|
expect(s.resolveLevel).toBe('steady')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses markdown resolve config for wrong-verb cost', () => {
|
||||||
|
const configured = withResolveMechanic({ wrongVerbCost: 2 })
|
||||||
|
let s = initialStateFor(configured)
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, configured).state
|
||||||
|
const r = dispatch(s, { kind: 'verb-target', verb: 'push', target: { canonical: 'revenant', raw: 'revenant' } }, configured)
|
||||||
|
expect(r.state.resolveLevel).toBe('reeling')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses markdown resolve config for safe-room recovery', () => {
|
||||||
|
const configured = withResolveMechanic({ safeRooms: { recoverySteps: 2 } })
|
||||||
|
let s = initialStateFor(configured)
|
||||||
|
s = { ...s, resolveLevel: 'reeling' }
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, configured).state
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 's' }, configured).state
|
||||||
|
expect(s.resolveLevel).toBe('steady')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses markdown resolve config for post-retreat level', () => {
|
||||||
|
const configured = withResolveMechanic({ failure: { retreatAt: 'returning', afterRetreat: 'reeling' } })
|
||||||
|
let s = initialStateFor(configured)
|
||||||
|
s = dispatch(s, { kind: 'go', direction: 'n' }, configured).state
|
||||||
|
s = { ...s, resolveLevel: 'returning' }
|
||||||
|
s = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, configured).state
|
||||||
|
const r = dispatch(s, { kind: 'confirmation', confirmed: true }, configured)
|
||||||
|
expect(r.state.location).toBe('foyer')
|
||||||
|
expect(r.state.resolveLevel).toBe('reeling')
|
||||||
|
})
|
||||||
|
|
||||||
it('allows a required item to be the direct target in a target-preposition encounter command', () => {
|
it('allows a required item to be the direct target in a target-preposition encounter command', () => {
|
||||||
let s = initialStateFor(world)
|
let s = initialStateFor(world)
|
||||||
s = {
|
s = {
|
||||||
|
|||||||
+39
-12
@@ -2,6 +2,17 @@ import type { World } from '../world/types'
|
|||||||
import type { GameState, ParsedCommand, DispatchResult, TranscriptLine, ResolveLevel } from './types'
|
import type { GameState, ParsedCommand, DispatchResult, TranscriptLine, ResolveLevel } from './types'
|
||||||
import { TRANSCRIPT_CAP, RESOLVE_LEVELS } from './types'
|
import { TRANSCRIPT_CAP, RESOLVE_LEVELS } from './types'
|
||||||
|
|
||||||
|
type ActiveResolveMechanic = NonNullable<NonNullable<World['mechanics']>['resolve']>
|
||||||
|
|
||||||
|
const DEFAULT_RESOLVE_MECHANIC: ActiveResolveMechanic = {
|
||||||
|
enabled: true,
|
||||||
|
handler: 'resolve',
|
||||||
|
ladder: RESOLVE_LEVELS,
|
||||||
|
wrongVerbCost: 1,
|
||||||
|
safeRooms: { recoverySteps: 1 },
|
||||||
|
failure: { retreatAt: 'returning', afterRetreat: 'shaken' },
|
||||||
|
}
|
||||||
|
|
||||||
function append(state: GameState, lines: TranscriptLine[]): GameState {
|
function append(state: GameState, lines: TranscriptLine[]): GameState {
|
||||||
const transcript = [...state.transcript, ...lines]
|
const transcript = [...state.transcript, ...lines]
|
||||||
return { ...state, transcript: transcript.slice(-TRANSCRIPT_CAP) }
|
return { ...state, transcript: transcript.slice(-TRANSCRIPT_CAP) }
|
||||||
@@ -38,11 +49,26 @@ export function maybeTriggerEncounter(state: GameState, world: World): DispatchR
|
|||||||
return narrate(next, [{ kind: 'narration', text: phase.description }])
|
return narrate(next, [{ kind: 'narration', text: phase.description }])
|
||||||
}
|
}
|
||||||
|
|
||||||
function bumpResolve(level: ResolveLevel, cost: 0 | 1 | 2 | undefined): ResolveLevel {
|
function resolveMechanic(world: World): ActiveResolveMechanic {
|
||||||
if (!cost) return level
|
return world.mechanics?.resolve ?? DEFAULT_RESOLVE_MECHANIC
|
||||||
const idx = RESOLVE_LEVELS.indexOf(level)
|
}
|
||||||
const newIdx = Math.min(RESOLVE_LEVELS.length - 1, idx + cost)
|
|
||||||
return RESOLVE_LEVELS[newIdx]!
|
function bumpResolve(level: ResolveLevel, cost: 0 | 1 | 2 | undefined, world: World): ResolveLevel {
|
||||||
|
const mechanic = resolveMechanic(world)
|
||||||
|
if (!mechanic.enabled || !cost) return level
|
||||||
|
const idx = mechanic.ladder.indexOf(level)
|
||||||
|
if (idx < 0) return level
|
||||||
|
const newIdx = Math.min(mechanic.ladder.length - 1, idx + cost)
|
||||||
|
return mechanic.ladder[newIdx]!
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetreat(level: ResolveLevel, cost: 0 | 1 | 2 | undefined, world: World): boolean {
|
||||||
|
const mechanic = resolveMechanic(world)
|
||||||
|
return mechanic.enabled && !!cost && level === mechanic.failure.retreatAt
|
||||||
|
}
|
||||||
|
|
||||||
|
function afterRetreatResolve(world: World): ResolveLevel {
|
||||||
|
return resolveMechanic(world).failure.afterRetreat
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EncounterResolution {
|
export interface EncounterResolution {
|
||||||
@@ -103,12 +129,13 @@ export function applyVerbToEncounter(
|
|||||||
if (!transition) {
|
if (!transition) {
|
||||||
// Wrong verb — apply default narration and resolve cost.
|
// Wrong verb — apply default narration and resolve cost.
|
||||||
if (!verb || (targetId !== null && targetId !== encId)) return null // verb is unrelated to this encounter
|
if (!verb || (targetId !== null && targetId !== encId)) return null // verb is unrelated to this encounter
|
||||||
const newResolve = bumpResolve(state.resolveLevel, 1)
|
const wrongVerbCost = resolveMechanic(world).wrongVerbCost
|
||||||
if (state.resolveLevel === 'returning') {
|
const newResolve = bumpResolve(state.resolveLevel, wrongVerbCost, world)
|
||||||
|
if (shouldRetreat(state.resolveLevel, wrongVerbCost, world)) {
|
||||||
// Retreat.
|
// Retreat.
|
||||||
const retreat = def.onFailed
|
const retreat = def.onFailed
|
||||||
if (retreat) {
|
if (retreat) {
|
||||||
const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: 'shaken' }
|
const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: afterRetreatResolve(world) }
|
||||||
const dest = world.rooms[retreat.retreatTo]
|
const dest = world.rooms[retreat.retreatTo]
|
||||||
const lines: TranscriptLine[] = [
|
const lines: TranscriptLine[] = [
|
||||||
{ kind: 'narration', text: retreat.narration },
|
{ kind: 'narration', text: retreat.narration },
|
||||||
@@ -125,10 +152,10 @@ export function applyVerbToEncounter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Right verb — but if it has a resolve cost and player is already at 'returning', retreat.
|
// Right verb — but if it has a resolve cost and player is already at 'returning', retreat.
|
||||||
if (transition.resolveCost && transition.resolveCost > 0 && state.resolveLevel === 'returning') {
|
if (shouldRetreat(state.resolveLevel, transition.resolveCost, world)) {
|
||||||
const retreat = def.onFailed
|
const retreat = def.onFailed
|
||||||
if (retreat) {
|
if (retreat) {
|
||||||
const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: 'shaken' }
|
const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: afterRetreatResolve(world) }
|
||||||
const dest = world.rooms[retreat.retreatTo]
|
const dest = world.rooms[retreat.retreatTo]
|
||||||
const lines: TranscriptLine[] = [
|
const lines: TranscriptLine[] = [
|
||||||
{ kind: 'narration', text: transition.narration },
|
{ kind: 'narration', text: transition.narration },
|
||||||
@@ -142,7 +169,7 @@ export function applyVerbToEncounter(
|
|||||||
// Right verb — narrate and transition.
|
// Right verb — narrate and transition.
|
||||||
let next: GameState = { ...state }
|
let next: GameState = { ...state }
|
||||||
if (transition.resolveCost) {
|
if (transition.resolveCost) {
|
||||||
next = { ...next, resolveLevel: bumpResolve(next.resolveLevel, transition.resolveCost) }
|
next = { ...next, resolveLevel: bumpResolve(next.resolveLevel, transition.resolveCost, world) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transition.to === 'resolved') {
|
if (transition.to === 'resolved') {
|
||||||
@@ -158,7 +185,7 @@ export function applyVerbToEncounter(
|
|||||||
const dest = world.rooms[retreat.retreatTo]
|
const dest = world.rooms[retreat.retreatTo]
|
||||||
const newEncState = { ...next.encounterState }
|
const newEncState = { ...next.encounterState }
|
||||||
delete newEncState[encId]
|
delete newEncState[encId]
|
||||||
next = { ...next, location: retreat.retreatTo, encounterState: newEncState, resolveLevel: 'shaken' }
|
next = { ...next, location: retreat.retreatTo, encounterState: newEncState, resolveLevel: afterRetreatResolve(world) }
|
||||||
const lines: TranscriptLine[] = [
|
const lines: TranscriptLine[] = [
|
||||||
{ kind: 'narration', text: transition.narration },
|
{ kind: 'narration', text: transition.narration },
|
||||||
{ kind: 'narration', text: retreat.narration },
|
{ kind: 'narration', text: retreat.narration },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { parse } from './parser'
|
import { parse } from './parser'
|
||||||
import type { ParserContext } from './parser'
|
import type { ParserContext, ParserVocabulary } from './parser'
|
||||||
|
|
||||||
const emptyCtx: ParserContext = {
|
const emptyCtx: ParserContext = {
|
||||||
knownItems: [],
|
knownItems: [],
|
||||||
@@ -92,6 +92,45 @@ describe('parser — unknown input', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('parser — verb + target', () => {
|
describe('parser — verb + target', () => {
|
||||||
|
it('uses vocabulary supplied by world markdown', () => {
|
||||||
|
const vocabulary: ParserVocabulary = {
|
||||||
|
directions: {
|
||||||
|
n: ['n', 'northward'],
|
||||||
|
s: ['s'],
|
||||||
|
e: ['e'],
|
||||||
|
w: ['w'],
|
||||||
|
u: ['u'],
|
||||||
|
d: ['d'],
|
||||||
|
},
|
||||||
|
prepositions: ['beside'],
|
||||||
|
stopWords: ['the'],
|
||||||
|
noTargetVerbs: ['look'],
|
||||||
|
metaVerbs: ['restart'],
|
||||||
|
verbs: {
|
||||||
|
go: ['go'],
|
||||||
|
look: ['look', 'observe'],
|
||||||
|
take: ['take hold of'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const ctx: ParserContext = {
|
||||||
|
knownItems: ['lamp'],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [{ id: 'lamp', aliases: ['lamp'] }],
|
||||||
|
inventoryItemIds: [],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
vocabulary,
|
||||||
|
}
|
||||||
|
expect(parse('observe', ctx)).toEqual({ kind: 'verb-only', verb: 'look' })
|
||||||
|
expect(parse('northward', ctx)).toEqual({ kind: 'go', direction: 'n' })
|
||||||
|
expect(parse('go northward', ctx)).toEqual({ kind: 'go', direction: 'n' })
|
||||||
|
expect(parse('take hold of the lamp', ctx)).toEqual({
|
||||||
|
kind: 'verb-target',
|
||||||
|
verb: 'take',
|
||||||
|
target: { canonical: 'lamp', raw: 'lamp' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('recognizes slice-two encounter verbs', () => {
|
it('recognizes slice-two encounter verbs', () => {
|
||||||
const ctx: ParserContext = {
|
const ctx: ParserContext = {
|
||||||
knownItems: [],
|
knownItems: [],
|
||||||
|
|||||||
+130
-70
@@ -1,5 +1,14 @@
|
|||||||
import type { ParsedCommand, NounRef, Verb, MetaVerb, Direction, PendingDisambiguation } from './types'
|
import type { ParsedCommand, NounRef, Verb, MetaVerb, Direction, PendingDisambiguation } from './types'
|
||||||
|
|
||||||
|
export interface ParserVocabulary {
|
||||||
|
directions: Record<Direction, string[]>
|
||||||
|
prepositions: string[]
|
||||||
|
stopWords: string[]
|
||||||
|
noTargetVerbs: Verb[]
|
||||||
|
metaVerbs: MetaVerb[]
|
||||||
|
verbs: Partial<Record<Verb, string[]>>
|
||||||
|
}
|
||||||
|
|
||||||
export interface ParserContext {
|
export interface ParserContext {
|
||||||
/** All item ids that exist in the world (for noun matching). */
|
/** All item ids that exist in the world (for noun matching). */
|
||||||
knownItems: string[]
|
knownItems: string[]
|
||||||
@@ -11,64 +20,115 @@ export interface ParserContext {
|
|||||||
inventoryItemIds: string[]
|
inventoryItemIds: string[]
|
||||||
lastNoun: NounRef | null
|
lastNoun: NounRef | null
|
||||||
awaitingDisambiguation: PendingDisambiguation | null
|
awaitingDisambiguation: PendingDisambiguation | null
|
||||||
|
vocabulary?: ParserVocabulary
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verb synonym table: each entry maps an alias to the canonical Verb. */
|
export const SUPPORTED_VERBS: Verb[] = [
|
||||||
const VERB_SYNONYMS: Record<string, Verb> = {
|
'go',
|
||||||
// movement
|
'look',
|
||||||
go: 'go', walk: 'go', move: 'go',
|
'examine',
|
||||||
// perception
|
'take',
|
||||||
look: 'look', l: 'look',
|
'drop',
|
||||||
examine: 'examine', x: 'examine', inspect: 'examine',
|
'use',
|
||||||
// inventory
|
'open',
|
||||||
inventory: 'inventory', inv: 'inventory', i: 'inventory',
|
'close',
|
||||||
// manipulation
|
'read',
|
||||||
take: 'take', get: 'take', grab: 'take', 'pick up': 'take',
|
'light',
|
||||||
drop: 'drop', put: 'drop', leave: 'drop',
|
'extinguish',
|
||||||
use: 'use', combine: 'use',
|
'attack',
|
||||||
open: 'open', close: 'close',
|
'inventory',
|
||||||
drink: 'drink', sip: 'drink',
|
'wait',
|
||||||
read: 'read', light: 'light', extinguish: 'extinguish', douse: 'extinguish',
|
'hold',
|
||||||
attack: 'attack', kill: 'attack', fight: 'attack', strike: 'attack',
|
'push',
|
||||||
hold: 'hold', show: 'hold',
|
'pull',
|
||||||
push: 'push', press: 'push',
|
'cut',
|
||||||
pull: 'pull',
|
'play',
|
||||||
cut: 'cut', trim: 'cut',
|
'listen',
|
||||||
play: 'play',
|
'pour',
|
||||||
listen: 'listen',
|
'drink',
|
||||||
pour: 'pour',
|
]
|
||||||
uncover: 'open',
|
|
||||||
wait: 'wait', z: 'wait',
|
export const SUPPORTED_META_VERBS: MetaVerb[] = ['restart', 'undo', 'hint', 'save', 'quit', 'theme']
|
||||||
|
|
||||||
|
export const DEFAULT_PARSER_VOCABULARY: ParserVocabulary = {
|
||||||
|
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', 'hint', 'save', 'quit', 'theme'],
|
||||||
|
verbs: {
|
||||||
|
go: ['go', 'walk', 'move'],
|
||||||
|
look: ['look', 'l'],
|
||||||
|
examine: ['examine', 'x', 'inspect'],
|
||||||
|
inventory: ['inventory', 'inv', 'i'],
|
||||||
|
take: ['take', 'get', 'grab', 'pick up'],
|
||||||
|
drop: ['drop', 'put', 'leave'],
|
||||||
|
use: ['use', 'combine'],
|
||||||
|
open: ['open', 'uncover'],
|
||||||
|
close: ['close'],
|
||||||
|
drink: ['drink', 'sip'],
|
||||||
|
read: ['read'],
|
||||||
|
light: ['light'],
|
||||||
|
extinguish: ['extinguish', 'douse'],
|
||||||
|
attack: ['attack', 'kill', 'fight', 'strike'],
|
||||||
|
hold: ['hold', 'show'],
|
||||||
|
push: ['push', 'press'],
|
||||||
|
pull: ['pull'],
|
||||||
|
cut: ['cut', 'trim'],
|
||||||
|
play: ['play'],
|
||||||
|
listen: ['listen'],
|
||||||
|
pour: ['pour'],
|
||||||
|
wait: ['wait', 'z'],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIRECTION_WORDS: Record<string, Direction> = {
|
interface CompiledVocabulary {
|
||||||
n: 'n', north: 'n',
|
directionWords: Record<string, Direction>
|
||||||
s: 's', south: 's',
|
metaVerbs: Record<string, MetaVerb>
|
||||||
e: 'e', east: 'e',
|
verbSynonyms: Record<string, Verb>
|
||||||
w: 'w', west: 'w',
|
multiWordVerbs: string[]
|
||||||
u: 'u', up: 'u',
|
noTargetVerbs: Set<string>
|
||||||
d: 'd', down: 'd',
|
stopWords: Set<string>
|
||||||
|
prepositions: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
const META_VERBS: Record<string, MetaVerb> = {
|
function normalizeAlias(value: string): string {
|
||||||
restart: 'restart',
|
return value.trim().toLowerCase().replace(/\s+/g, ' ')
|
||||||
undo: 'undo',
|
|
||||||
hint: 'hint',
|
|
||||||
save: 'save',
|
|
||||||
quit: 'quit',
|
|
||||||
theme: 'theme',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verbs that legally take no target. */
|
function compileVocabulary(vocabulary: ParserVocabulary): CompiledVocabulary {
|
||||||
const VERB_ONLY_VERBS = new Set<string>(['look', 'inventory', 'wait', 'listen'])
|
const directionWords: Record<string, Direction> = {}
|
||||||
|
for (const [direction, aliases] of Object.entries(vocabulary.directions) as [Direction, string[]][]) {
|
||||||
|
for (const alias of aliases) directionWords[normalizeAlias(alias)] = direction
|
||||||
|
}
|
||||||
|
|
||||||
/** Two-word verb prefixes (e.g. "pick up X"). */
|
const metaVerbs: Record<string, MetaVerb> = {}
|
||||||
const TWO_WORD_VERBS = ['pick up']
|
for (const verb of vocabulary.metaVerbs) metaVerbs[normalizeAlias(verb)] = verb
|
||||||
|
|
||||||
/** Leading stop-words stripped from the noun phrase before matching. */
|
const verbSynonyms: Record<string, Verb> = {}
|
||||||
const STOP_WORDS = new Set(['at', 'the', 'a', 'an'])
|
for (const [verb, aliases] of Object.entries(vocabulary.verbs) as [Verb, string[]][]) {
|
||||||
|
for (const alias of aliases) verbSynonyms[normalizeAlias(alias)] = verb
|
||||||
|
}
|
||||||
|
|
||||||
const PREPOSITIONS = new Set(['with', 'on', 'in', 'to'])
|
return {
|
||||||
|
directionWords,
|
||||||
|
metaVerbs,
|
||||||
|
verbSynonyms,
|
||||||
|
multiWordVerbs: Object.keys(verbSynonyms)
|
||||||
|
.filter((alias) => alias.includes(' '))
|
||||||
|
.sort((a, b) => b.split(' ').length - a.split(' ').length),
|
||||||
|
noTargetVerbs: new Set(vocabulary.noTargetVerbs),
|
||||||
|
stopWords: new Set(vocabulary.stopWords.map(normalizeAlias)),
|
||||||
|
prepositions: new Set(vocabulary.prepositions.map(normalizeAlias)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveNoun(rawTokens: string[], ctx: ParserContext): { id: string; alias: string } | null {
|
function resolveNoun(rawTokens: string[], ctx: ParserContext): { id: string; alias: string } | null {
|
||||||
const phrase = rawTokens.join(' ')
|
const phrase = rawTokens.join(' ')
|
||||||
@@ -90,19 +150,19 @@ function tokenize(input: string): string[] {
|
|||||||
return input.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
return input.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchTwoWordVerb(tokens: string[]): { verb: Verb; rest: string[] } | null {
|
function matchMultiWordVerb(tokens: string[], vocabulary: CompiledVocabulary): { verb: Verb; rest: string[] } | null {
|
||||||
if (tokens.length < 2) return null
|
for (const phrase of vocabulary.multiWordVerbs) {
|
||||||
const head = tokens.slice(0, 2).join(' ')
|
const phraseTokens = phrase.split(' ')
|
||||||
for (const phrase of TWO_WORD_VERBS) {
|
if (tokens.length >= phraseTokens.length && tokens.slice(0, phraseTokens.length).join(' ') === phrase) {
|
||||||
if (phrase === head) {
|
const verb = vocabulary.verbSynonyms[phrase]
|
||||||
const verb = VERB_SYNONYMS[phrase]
|
if (verb) return { verb, rest: tokens.slice(phraseTokens.length) }
|
||||||
if (verb) return { verb, rest: tokens.slice(2) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
||||||
|
const vocabulary = compileVocabulary(ctx.vocabulary ?? DEFAULT_PARSER_VOCABULARY)
|
||||||
const trimmed = rawInput.trim()
|
const trimmed = rawInput.trim()
|
||||||
if (!trimmed) return { kind: 'unknown', raw: '', reason: 'malformed' }
|
if (!trimmed) return { kind: 'unknown', raw: '', reason: 'malformed' }
|
||||||
|
|
||||||
@@ -117,19 +177,14 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Meta-commands take precedence (single-word).
|
// Meta-commands take precedence (single-word).
|
||||||
if (META_VERBS[head] && tokens.length === 1) {
|
if (vocabulary.metaVerbs[head] && tokens.length === 1) {
|
||||||
return { kind: 'meta', verb: META_VERBS[head]! }
|
return { kind: 'meta', verb: vocabulary.metaVerbs[head]! }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direction shortcuts: "n", "north", "go n", "go north".
|
// Direction shortcuts: "n", "north", "go n", "go north".
|
||||||
if (DIRECTION_WORDS[head] && tokens.length === 1) {
|
if (vocabulary.directionWords[head] && tokens.length === 1) {
|
||||||
return { kind: 'go', direction: DIRECTION_WORDS[head]! }
|
return { kind: 'go', direction: vocabulary.directionWords[head]! }
|
||||||
}
|
}
|
||||||
if (head === 'go' && tokens.length === 2) {
|
|
||||||
const dir = DIRECTION_WORDS[tokens[1]!]
|
|
||||||
if (dir) return { kind: 'go', direction: dir }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disambiguation reply: a single-word answer matching one of the candidates.
|
// Disambiguation reply: a single-word answer matching one of the candidates.
|
||||||
// Must be checked before verb resolution so "brass" / "iron" etc. are caught.
|
// Must be checked before verb resolution so "brass" / "iron" etc. are caught.
|
||||||
if (ctx.awaitingDisambiguation && tokens.length === 1) {
|
if (ctx.awaitingDisambiguation && tokens.length === 1) {
|
||||||
@@ -144,15 +199,15 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two-word verb (e.g. "pick up X").
|
// Multi-word verb aliases (e.g. "pick up X").
|
||||||
const twoWord = matchTwoWordVerb(tokens)
|
const twoWord = matchMultiWordVerb(tokens, vocabulary)
|
||||||
let verb: Verb | undefined
|
let verb: Verb | undefined
|
||||||
let rest: string[]
|
let rest: string[]
|
||||||
if (twoWord) {
|
if (twoWord) {
|
||||||
verb = twoWord.verb
|
verb = twoWord.verb
|
||||||
rest = twoWord.rest
|
rest = twoWord.rest
|
||||||
} else {
|
} else {
|
||||||
verb = VERB_SYNONYMS[head]
|
verb = vocabulary.verbSynonyms[head]
|
||||||
rest = tokens.slice(1)
|
rest = tokens.slice(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,26 +215,31 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
|||||||
return { kind: 'unknown', raw: trimmed, reason: 'unknown-verb' }
|
return { kind: 'unknown', raw: trimmed, reason: 'unknown-verb' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (verb === 'go' && rest.length === 1) {
|
||||||
|
const dir = vocabulary.directionWords[rest[0]!]
|
||||||
|
if (dir) return { kind: 'go', direction: dir }
|
||||||
|
}
|
||||||
|
|
||||||
// Strip leading stop-words from the noun phrase (e.g. "at", "the", "a", "an").
|
// Strip leading stop-words from the noun phrase (e.g. "at", "the", "a", "an").
|
||||||
while (rest.length > 0 && STOP_WORDS.has(rest[0]!)) {
|
while (rest.length > 0 && vocabulary.stopWords.has(rest[0]!)) {
|
||||||
rest = rest.slice(1)
|
rest = rest.slice(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rest.length === 0) {
|
if (rest.length === 0) {
|
||||||
if (VERB_ONLY_VERBS.has(verb)) {
|
if (vocabulary.noTargetVerbs.has(verb)) {
|
||||||
return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' | 'listen' }
|
return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' | 'listen' }
|
||||||
}
|
}
|
||||||
return { kind: 'unknown', raw: trimmed, reason: 'malformed' }
|
return { kind: 'unknown', raw: trimmed, reason: 'malformed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect a preposition splitting target | indirect.
|
// Detect a preposition splitting target | indirect.
|
||||||
const prepIdx = rest.findIndex((tok) => PREPOSITIONS.has(tok))
|
const prepIdx = rest.findIndex((tok) => vocabulary.prepositions.has(tok))
|
||||||
if (prepIdx > 0 && prepIdx < rest.length - 1) {
|
if (prepIdx > 0 && prepIdx < rest.length - 1) {
|
||||||
const targetTokens = rest.slice(0, prepIdx)
|
const targetTokens = rest.slice(0, prepIdx)
|
||||||
const prep = rest[prepIdx]!
|
const prep = rest[prepIdx]!
|
||||||
let indirectTokens = rest.slice(prepIdx + 1)
|
let indirectTokens = rest.slice(prepIdx + 1)
|
||||||
// Strip stop-words at the head of the indirect phrase too ("on the table").
|
// Strip stop-words at the head of the indirect phrase too ("on the table").
|
||||||
while (indirectTokens.length > 0 && STOP_WORDS.has(indirectTokens[0]!)) {
|
while (indirectTokens.length > 0 && vocabulary.stopWords.has(indirectTokens[0]!)) {
|
||||||
indirectTokens = indirectTokens.slice(1)
|
indirectTokens = indirectTokens.slice(1)
|
||||||
}
|
}
|
||||||
if (indirectTokens.length > 0) {
|
if (indirectTokens.length > 0) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function ctxFor(state: GameState): ParserContext {
|
|||||||
inventoryItemIds: state.inventory.map((i) => i.id),
|
inventoryItemIds: state.inventory.map((i) => i.id),
|
||||||
lastNoun: state.lastNoun,
|
lastNoun: state.lastNoun,
|
||||||
awaitingDisambiguation: state.pendingDisambiguation,
|
awaitingDisambiguation: state.pendingDisambiguation,
|
||||||
|
vocabulary: world.parser,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -40,7 +40,7 @@ export interface ItemInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type EncounterPhase = string // phase names are encounter-specific
|
export type EncounterPhase = string // phase names are encounter-specific
|
||||||
export type EndingId = 'true' | 'wrong' | 'bad' | 'replacement' | 'mercy'
|
export type EndingId = string
|
||||||
|
|
||||||
export interface TranscriptLine {
|
export interface TranscriptLine {
|
||||||
kind: 'narration' | 'player' | 'system' | 'ending'
|
kind: 'narration' | 'player' | 'system' | 'ending'
|
||||||
@@ -60,6 +60,7 @@ export interface PendingConfirmation {
|
|||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
schemaVersion: number
|
schemaVersion: number
|
||||||
|
transcriptCap?: number
|
||||||
location: RoomId
|
location: RoomId
|
||||||
inventory: ItemInstance[]
|
inventory: ItemInstance[]
|
||||||
/** Per-room state: visited, items dropped, descriptive flags. */
|
/** Per-room state: visited, items dropped, descriptive flags. */
|
||||||
|
|||||||
+31
-10
@@ -1,22 +1,27 @@
|
|||||||
---
|
---
|
||||||
import '../ui/crt.css'
|
import '../ui/crt.css'
|
||||||
|
import { world } from '../world'
|
||||||
|
|
||||||
const buildNumber = process.env.CI_PIPELINE_NUMBER ?? 'local'
|
const buildNumber = process.env.CI_PIPELINE_NUMBER ?? 'local'
|
||||||
|
const ui = world.ui
|
||||||
|
const footerLinks = ui?.footer.links ?? []
|
||||||
|
const firstFooterLink = footerLinks[0]
|
||||||
|
const remainingFooterLinks = footerLinks.slice(1)
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>Halfstreet — Ethan J Lewis</title>
|
<title>{ui?.pageTitle ?? `${world.game?.title ?? 'Halfstreet'} - Ethan J Lewis`}</title>
|
||||||
<meta name="description" content="A gothic mystery." />
|
<meta name="description" content={ui?.description ?? world.game?.description ?? 'A gothic mystery.'} />
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content={ui?.robots ?? 'noindex'} />
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="icon" href="/favicon-96x96.png" type="image/png" sizes="96x96" />
|
<link rel="icon" href="/favicon-96x96.png" type="image/png" sizes="96x96" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<meta name="theme-color" content="#1a0d00" />
|
<meta name="theme-color" content={ui?.themeColor ?? '#1a0d00'} />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="mystery-root" data-mystery-root>
|
<div class="mystery-root" data-mystery-root>
|
||||||
@@ -52,16 +57,20 @@ const buildNumber = process.env.CI_PIPELINE_NUMBER ?? 'local'
|
|||||||
</div>
|
</div>
|
||||||
<div class="mystery-options-group" aria-label="Game">
|
<div class="mystery-options-group" aria-label="Game">
|
||||||
<div class="mystery-options-label">Game</div>
|
<div class="mystery-options-label">Game</div>
|
||||||
|
{ui?.features.chips !== false && (
|
||||||
|
<>
|
||||||
<button type="button" data-chips-choice="on" aria-pressed="true">Chips On</button>
|
<button type="button" data-chips-choice="on" aria-pressed="true">Chips On</button>
|
||||||
<button type="button" data-chips-choice="off" aria-pressed="false">Chips Off</button>
|
<button type="button" data-chips-choice="off" aria-pressed="false">Chips Off</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button type="button" data-restart-choice>Restart</button>
|
<button type="button" data-restart-choice>Restart</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mystery-transcript" data-mystery-transcript aria-live="polite" aria-atomic="false"></div>
|
<div class="mystery-transcript" data-mystery-transcript aria-live="polite" aria-atomic="false"></div>
|
||||||
<div class="mystery-controls">
|
<div class="mystery-controls">
|
||||||
<div class="mystery-chips" data-mystery-chips></div>
|
{ui?.features.chips !== false && <div class="mystery-chips" data-mystery-chips></div>}
|
||||||
<div class="mystery-light-meter" data-mystery-light-meter aria-hidden="true"></div>
|
{ui?.features.lightMeter !== false && <div class="mystery-light-meter" data-mystery-light-meter aria-hidden="true"></div>}
|
||||||
</div>
|
</div>
|
||||||
<div class="mystery-input-row">
|
<div class="mystery-input-row">
|
||||||
<input
|
<input
|
||||||
@@ -78,13 +87,25 @@ const buildNumber = process.env.CI_PIPELINE_NUMBER ?? 'local'
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer class="mystery-footer">
|
<footer class="mystery-footer">
|
||||||
© 2026 <a href="https://ethanjlewis.com">Ethan J Lewis</a>
|
{ui?.footer.copyrightHref ? <a href={ui.footer.copyrightHref}>{ui.footer.copyright}</a> : <span>{ui?.footer.copyright ?? '© 2026 Ethan J Lewis'}</span>}
|
||||||
|
{firstFooterLink && (
|
||||||
|
<>
|
||||||
<span aria-hidden="true">|</span>
|
<span aria-hidden="true">|</span>
|
||||||
<a href="https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE">GNU 3.0</a>
|
<a href={firstFooterLink.href}>{firstFooterLink.label}</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{ui?.footer.showBuild !== false && (
|
||||||
|
<>
|
||||||
<span aria-hidden="true">|</span>
|
<span aria-hidden="true">|</span>
|
||||||
<span>Build #{buildNumber}</span>
|
<span>{ui?.footer.buildLabel ?? 'Build #'}{buildNumber}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{remainingFooterLinks.map((link) => (
|
||||||
|
<>
|
||||||
<span aria-hidden="true">|</span>
|
<span aria-hidden="true">|</span>
|
||||||
<a href="https://half.st/ejlewis/halfstreet">Source Code</a>
|
<a href={link.href}>{link.label}</a>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
+20
-23
@@ -1,8 +1,9 @@
|
|||||||
import { parse } from '../engine/parser'
|
import { parse } from '../engine/parser'
|
||||||
import type { ParserContext } from '../engine/parser'
|
import type { ParserContext } from '../engine/parser'
|
||||||
import { dispatch, initialStateFor, getItemsInRoom, getLightStatus, LIGHT_TURNS_MAX } from '../engine/dispatcher'
|
import { dispatch, initialStateFor, getItemsInRoom, getLightStatus } from '../engine/dispatcher'
|
||||||
import { saveState, loadState, clearSave } from '../engine/save'
|
import { saveState, loadState, clearSave } from '../engine/save'
|
||||||
import { world } from '../world'
|
import { world } from '../world'
|
||||||
|
import { DEFAULT_WORLD_MESSAGES, type WorldMessageKey } from '../world/types'
|
||||||
import type { GameState, TranscriptLine } from '../engine/types'
|
import type { GameState, TranscriptLine } from '../engine/types'
|
||||||
import { TRANSCRIPT_CAP } from '../engine/types'
|
import { TRANSCRIPT_CAP } from '../engine/types'
|
||||||
import { computeChips } from './chips'
|
import { computeChips } from './chips'
|
||||||
@@ -14,24 +15,17 @@ const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
|
|||||||
const inputDisplayEl = document.querySelector<HTMLSpanElement>('[data-mystery-input-display]')
|
const inputDisplayEl = document.querySelector<HTMLSpanElement>('[data-mystery-input-display]')
|
||||||
const lightMeterEl = document.querySelector<HTMLDivElement>('[data-mystery-light-meter]')
|
const lightMeterEl = document.querySelector<HTMLDivElement>('[data-mystery-light-meter]')
|
||||||
|
|
||||||
const HELP_TEXT = `You arrive at the address, but you do not remember what has happened. The road behind you is gone...
|
const HELP_TEXT = world.game?.helpText ?? `This is a text adventure. Type short commands to act.`
|
||||||
|
const UI_FEATURES = world.ui?.features ?? {
|
||||||
|
chips: true,
|
||||||
|
lightMeter: true,
|
||||||
|
typedEffect: true,
|
||||||
|
roomScroll: true,
|
||||||
|
}
|
||||||
|
|
||||||
This is a text adventure. Type short commands to act in the house.
|
function message(key: WorldMessageKey): string {
|
||||||
|
return world.messages?.[key] ?? DEFAULT_WORLD_MESSAGES[key]
|
||||||
Common commands:
|
}
|
||||||
look describe the room again
|
|
||||||
n, s, e, w, u, d move by direction
|
|
||||||
take lamp pick something up
|
|
||||||
examine letter inspect something nearby or held
|
|
||||||
read letter read a readable object
|
|
||||||
inventory see what you carry
|
|
||||||
light lamp with matches use one thing with another
|
|
||||||
wait let the room continue
|
|
||||||
undo step back once
|
|
||||||
restart begin again
|
|
||||||
theme change the terminal colors
|
|
||||||
|
|
||||||
Most commands are verb first, then the thing: examine gate, take lamp, use key on door.`
|
|
||||||
|
|
||||||
if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
||||||
console.error('[halfstreet] terminal mount points missing')
|
console.error('[halfstreet] terminal mount points missing')
|
||||||
@@ -53,6 +47,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
const ROOM_SCROLL_MS = 180
|
const ROOM_SCROLL_MS = 180
|
||||||
|
|
||||||
const syncLightMeter = (): void => {
|
const syncLightMeter = (): void => {
|
||||||
|
if (!UI_FEATURES.lightMeter) return
|
||||||
if (!lightMeterEl) return
|
if (!lightMeterEl) return
|
||||||
const status = getLightStatus(state, world)
|
const status = getLightStatus(state, world)
|
||||||
lightMeterEl.hidden = !status
|
lightMeterEl.hidden = !status
|
||||||
@@ -75,8 +70,8 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
|
|
||||||
const leds = document.createElement('div')
|
const leds = document.createElement('div')
|
||||||
leds.className = 'mystery-light-leds'
|
leds.className = 'mystery-light-leds'
|
||||||
const turnsLeft = Math.max(0, Math.min(LIGHT_TURNS_MAX, status.turnsLeft))
|
const turnsLeft = Math.max(0, Math.min(status.maxTurns, status.turnsLeft))
|
||||||
for (let i = 0; i < LIGHT_TURNS_MAX; i++) {
|
for (let i = 0; i < status.maxTurns; i++) {
|
||||||
const segment = document.createElement('span')
|
const segment = document.createElement('span')
|
||||||
segment.className = 'mystery-light-segment'
|
segment.className = 'mystery-light-segment'
|
||||||
const lit = i < turnsLeft
|
const lit = i < turnsLeft
|
||||||
@@ -98,6 +93,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshChips(): void {
|
function refreshChips(): void {
|
||||||
|
if (!UI_FEATURES.chips) return
|
||||||
renderChips(computeChips(state, world), (command) => {
|
renderChips(computeChips(state, world), (command) => {
|
||||||
clearIdleHint()
|
clearIdleHint()
|
||||||
inputEl!.value = command
|
inputEl!.value = command
|
||||||
@@ -160,6 +156,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
inventoryItemIds: s.inventory.map((i) => i.id),
|
inventoryItemIds: s.inventory.map((i) => i.id),
|
||||||
lastNoun: s.lastNoun,
|
lastNoun: s.lastNoun,
|
||||||
awaitingDisambiguation: s.pendingDisambiguation,
|
awaitingDisambiguation: s.pendingDisambiguation,
|
||||||
|
vocabulary: world.parser,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +271,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderAll = (lines: TranscriptLine[], options: { animate?: boolean; scroll?: boolean } = {}): void => {
|
const renderAll = (lines: TranscriptLine[], options: { animate?: boolean; scroll?: boolean } = {}): void => {
|
||||||
const animate = options.animate ?? true
|
const animate = (options.animate ?? true) && UI_FEATURES.typedEffect
|
||||||
const shouldScroll = options.scroll ?? true
|
const shouldScroll = options.scroll ?? true
|
||||||
const generation = renderGeneration
|
const generation = renderGeneration
|
||||||
renderQueue = renderQueue.then(() => renderLines(lines, animate, shouldScroll, generation)).catch((err) => {
|
renderQueue = renderQueue.then(() => renderLines(lines, animate, shouldScroll, generation)).catch((err) => {
|
||||||
@@ -445,7 +442,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
syncEndedUI()
|
syncEndedUI()
|
||||||
syncDrunkEffect()
|
syncDrunkEffect()
|
||||||
} else {
|
} else {
|
||||||
appendLines([{ kind: 'system', text: 'There is no further back.' }], { scroll: false })
|
appendLines([{ kind: 'system', text: message('no-undo') }], { scroll: false })
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -463,7 +460,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
const previousLocation = state.location
|
const previousLocation = state.location
|
||||||
const result = dispatch(state, command, world)
|
const result = dispatch(state, command, world)
|
||||||
state = result.state
|
state = result.state
|
||||||
const shouldScrollToRoom = command.kind === 'go' && state.location !== previousLocation
|
const shouldScrollToRoom = UI_FEATURES.roomScroll && command.kind === 'go' && state.location !== previousLocation
|
||||||
renderAll(result.appended, { scroll: shouldScrollToRoom }) // dispatch already pushed these into state.transcript
|
renderAll(result.appended, { scroll: shouldScrollToRoom }) // dispatch already pushed these into state.transcript
|
||||||
saveState(state)
|
saveState(state)
|
||||||
if (raw.trim().toLowerCase() === 'theme') {
|
if (raw.trim().toLowerCase() === 'theme') {
|
||||||
|
|||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"markdown:add-metadata-property": [],
|
||||||
|
"editor:toggle-source": [
|
||||||
|
{
|
||||||
|
"modifiers": [
|
||||||
|
"Mod"
|
||||||
|
],
|
||||||
|
"key": ";"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -46,3 +46,4 @@
|
|||||||
- [ ] BUG: It says the door closes behind you when you enter the lobby, but you can still exit S to the gate.
|
- [ ] BUG: It says the door closes behind you when you enter the lobby, but you can still exit S to the gate.
|
||||||
- [x] FEATURE: Add a short "typed" effect to the text. Make it look like it's being typed out, if that makes sense, one character at a time. The effect should be brief.
|
- [x] FEATURE: Add a short "typed" effect to the text. Make it look like it's being typed out, if that makes sense, one character at a time. The effect should be brief.
|
||||||
- [x] FEATURE: Whenever you change rooms, scroll the text so the name of the room you're in is at the top. Users can scroll up to see the history. This should be an effect where the old text slides up to make room for the new text, and this should happen before the "typed" effect.
|
- [x] FEATURE: Whenever you change rooms, scroll the text so the name of the room you're in is at the top. Users can scroll up to see the history. This should be an effect where the old text slides up to make room for the new text, and this should happen before the "typed" effect.
|
||||||
|
- [ ] Open-source authoring architecture: follow [[open-source-authoring-roadmap]] to move as much story, parser, mechanic, action, and UI configuration as practical into markdown under `src/world` so authors can control it from Obsidian.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
id: burn-letter
|
||||||
|
verbs: [use]
|
||||||
|
requires:
|
||||||
|
allVisibleOrHeld:
|
||||||
|
- "[[letter]]"
|
||||||
|
- "[[matches]]"
|
||||||
|
consumes:
|
||||||
|
inventory:
|
||||||
|
- "[[letter]]"
|
||||||
|
decrements:
|
||||||
|
item: "[[matches]]"
|
||||||
|
stateKey: uses
|
||||||
|
setsFlags:
|
||||||
|
letterBurned: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Burn Letter
|
||||||
|
|
||||||
|
## success
|
||||||
|
The letter catches at one corner. In a few breaths it is ash.
|
||||||
|
|
||||||
|
## spent
|
||||||
|
The matchbook is empty.
|
||||||
|
|
||||||
|
## missingRequired
|
||||||
|
You don't see the letter here.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
id: drink-whiskey
|
||||||
|
verbs: [drink]
|
||||||
|
handler: drunk-transition
|
||||||
|
requires:
|
||||||
|
allHeld:
|
||||||
|
- "[[whiskey]]"
|
||||||
|
consumes:
|
||||||
|
inventory:
|
||||||
|
- "[[whiskey]]"
|
||||||
|
drunkTransition:
|
||||||
|
destinationRoom: "[[drunk-hall]]"
|
||||||
|
maxMoves: 20
|
||||||
|
wakeRoom: "[[foyer]]"
|
||||||
|
resetRoom: "[[kitchen]]"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Drink Whiskey
|
||||||
|
|
||||||
|
## success
|
||||||
|
You drink from the bottle. It tastes of smoke, sugar, and rainwater left too long in a pipe.
|
||||||
|
|
||||||
|
## missingRequired
|
||||||
|
You'd have to be carrying it.
|
||||||
|
|
||||||
|
## secretFoundPassOut
|
||||||
|
The faceless man steps backward into the dark. The floor rises under you, or you fall toward it.
|
||||||
|
|
||||||
|
## tooManyMovesPassOut
|
||||||
|
The rooms keep turning until they become one room. Then even that room is gone.
|
||||||
|
|
||||||
|
## reset
|
||||||
|
The bottle is not with you. Somewhere in the kitchen, it is half full again.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Authoring Halfstreet
|
||||||
|
|
||||||
|
Halfstreet is authored from this `src/world` vault. The TypeScript runtime loads and validates the markdown; ordinary story changes should start here.
|
||||||
|
|
||||||
|
Use wikilinks for references when you can. The loader accepts `[[foyer]]` and stores it as `foyer`, so Obsidian links stay useful without changing runtime ids.
|
||||||
|
|
||||||
|
## Forking The Game
|
||||||
|
|
||||||
|
The current open-source shape is a forkable Astro app. Keep `src/engine`, `src/ui`, and `src/pages` in place, then replace the markdown in this vault with your own rooms, items, encounters, endings, mechanics, actions, parser vocabulary, and UI labels.
|
||||||
|
|
||||||
|
For a new game, update these files first:
|
||||||
|
|
||||||
|
- `game.md`: title, starting room, starting inventory, ending priority, opening art, help text, and end text.
|
||||||
|
- `parser.md`: direction words, verb aliases, prepositions, stop words, no-target verbs, and meta verbs.
|
||||||
|
- `ui.md`: page title, meta description, footer labels and links, build label, and UI feature switches.
|
||||||
|
- `templates/`: copy these starter files when adding new world content.
|
||||||
|
|
||||||
|
After edits, run `npm test` from the repo root. Validation errors are meant to point back to the markdown field or section that needs fixing.
|
||||||
|
|
||||||
|
## Rooms
|
||||||
|
|
||||||
|
Room files live in `rooms/`. Every room needs frontmatter plus three prose sections:
|
||||||
|
|
||||||
|
- `## first-visit`
|
||||||
|
- `## revisit`
|
||||||
|
- `## examined`
|
||||||
|
|
||||||
|
Set unused exits to `null`. Set exits to `[[room-id]]` when they lead somewhere. Put item ids in `items`, and set `encounter` when a room starts an encounter.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: sample-room
|
||||||
|
title: "[ Sample Room ]"
|
||||||
|
exitN: "[[other-room]]"
|
||||||
|
exitS: null
|
||||||
|
exitE: null
|
||||||
|
exitW: null
|
||||||
|
exitU: null
|
||||||
|
exitD: null
|
||||||
|
items:
|
||||||
|
- "[[sample-key]]"
|
||||||
|
encounter: null
|
||||||
|
safe: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Locked exits use a matching `exitXRequires` and `exitXLockedText` pair. The requirement must be an item id or a known flag.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
exitN: "[[locked-room]]"
|
||||||
|
exitNRequires: sample-key
|
||||||
|
exitNLockedText: The door will not move.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Items
|
||||||
|
|
||||||
|
Item files live in `items/`. The prose before the first `##` header is the long description shown by `examine`.
|
||||||
|
|
||||||
|
Use `readable: true` only when the item has a `## read` section. Use `lightable: true` for items that can be lit, and `lighter: true` for items that can light other items.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: sample-key
|
||||||
|
names: ["key", "sample key"]
|
||||||
|
short: "a sample key"
|
||||||
|
takeable: true
|
||||||
|
initialState: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed optional item sections are:
|
||||||
|
|
||||||
|
- `## read`
|
||||||
|
- `## lit`
|
||||||
|
- `## extinguished`
|
||||||
|
- `## lighter-empty`
|
||||||
|
|
||||||
|
## Encounters
|
||||||
|
|
||||||
|
Encounter files live in `encounters/`. Encounters are state machines: `initialPhase` points to a phase, each phase points to a description section, and each transition points to a narration section.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: sample-encounter
|
||||||
|
startsIn: "[[sample-room]]"
|
||||||
|
initialPhase: waiting
|
||||||
|
aliases: [figure, shape]
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
waiting:
|
||||||
|
description: waiting
|
||||||
|
transitions:
|
||||||
|
- verb: wait
|
||||||
|
chipLabel: WAIT
|
||||||
|
narration: waited
|
||||||
|
to: resolved
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `onResolved.setFlags` for flags that endings or locked exits can read. Use `requires.item` on a transition when the player must hold a specific item.
|
||||||
|
|
||||||
|
## Endings
|
||||||
|
|
||||||
|
Ending files live in `endings/`. `whenFlags` controls when an ending is available, and the markdown body is the ending narration.
|
||||||
|
|
||||||
|
Quote ids that look like booleans:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: "true"
|
||||||
|
whenFlags:
|
||||||
|
familySecretKnown: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Bare `id: true` is parsed as a boolean by YAML before the loader sees it.
|
||||||
|
|
||||||
|
## Mechanics
|
||||||
|
|
||||||
|
Mechanic files live in `mechanics/`. They configure named TypeScript handlers without moving the algorithms into markdown.
|
||||||
|
|
||||||
|
`mechanics/light.md` controls the light timer, burn triggers, state keys, meter setting, and fallback light prose. `mechanics/resolve.md` controls resolve levels, wrong-verb cost, safe-room recovery, and retreat behavior.
|
||||||
|
|
||||||
|
Keep `handler` set to the supported handler name. Set `enabled: false` to disable a mechanic cleanly.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
Action files live in `actions/`. Simple actions can require visible or held items, consume inventory, decrement item state, set flags, and narrate success.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: sample-action
|
||||||
|
verbs: [use]
|
||||||
|
requires:
|
||||||
|
allVisibleOrHeld:
|
||||||
|
- "[[sample-key]]"
|
||||||
|
setsFlags:
|
||||||
|
sampleActionDone: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Every default action needs `## success`. Handler-backed actions may require extra sections. For example, the `drunk-transition` handler requires `## success`, `## secretFoundPassOut`, `## tooManyMovesPassOut`, and `## reset`.
|
||||||
|
|
||||||
|
## Game, Parser, Messages, And UI
|
||||||
|
|
||||||
|
- `game.md` controls the title, description, starting room, starting inventory, ending priority, transcript cap, opening art, help text, and ended text.
|
||||||
|
- `parser.md` controls directions, verb aliases, prepositions, stop words, no-target verbs, and meta verbs.
|
||||||
|
- `messages.md` controls common system prose.
|
||||||
|
- `ui.md` controls page metadata, footer labels and links, build label visibility, and UI feature toggles.
|
||||||
|
|
||||||
|
## Common Validation Errors
|
||||||
|
|
||||||
|
`missing required section "## first-visit"`: add the exact section header to the room file. Section names must use only letters, digits, hyphens, and underscores.
|
||||||
|
|
||||||
|
`## read section is required when readable: true`: either add `## read` to the item or remove `readable: true`.
|
||||||
|
|
||||||
|
`frontmatter references missing section`: an encounter phase, transition, or failure references a prose key that does not exist. Add the section or fix the key.
|
||||||
|
|
||||||
|
`exitNRequires is set but exitNLockedText is missing`: locked exits need both the requirement and locked narration fields.
|
||||||
|
|
||||||
|
`references unknown item` or `references unknown room`: check the wikilink/id spelling and make sure the referenced file exists in the matching folder.
|
||||||
|
|
||||||
|
`endingPriority references "true" but endings/true.md is missing`: quote boolean-like ids in YAML and make sure the ending file exists.
|
||||||
|
|
||||||
|
`unknown message section` or `unknown item section`: the loader only accepts known section keys. Rename the header to one of the allowed keys for that file type.
|
||||||
@@ -1,7 +1,83 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { world } from './index'
|
import { assembleWorld, world } from './index'
|
||||||
|
import type { GameManifest, Item, Room, World } from './types'
|
||||||
|
|
||||||
|
const manifest: GameManifest = {
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'A tiny test world.',
|
||||||
|
startingRoom: 'foyer',
|
||||||
|
startingInventory: ['letter'],
|
||||||
|
endingPriority: ['true'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const rooms: Record<string, Room> = {
|
||||||
|
foyer: {
|
||||||
|
id: 'foyer',
|
||||||
|
title: '[ Foyer ]',
|
||||||
|
descriptions: {
|
||||||
|
firstVisit: 'You are here.',
|
||||||
|
revisit: 'Still here.',
|
||||||
|
examined: 'A foyer.',
|
||||||
|
},
|
||||||
|
exits: {},
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: Record<string, Item> = {
|
||||||
|
letter: {
|
||||||
|
id: 'letter',
|
||||||
|
names: ['letter'],
|
||||||
|
short: 'a letter',
|
||||||
|
long: 'A folded letter.',
|
||||||
|
initialState: {},
|
||||||
|
takeable: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const endings: World['endings'] = {
|
||||||
|
true: { whenFlags: {}, narration: 'The end.' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(overrides: Partial<Parameters<typeof assembleWorld>[0]> = {}) {
|
||||||
|
return assembleWorld({
|
||||||
|
game: manifest,
|
||||||
|
rooms,
|
||||||
|
items,
|
||||||
|
endings,
|
||||||
|
encounters: {},
|
||||||
|
encounterDocs: [],
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe('assembled world', () => {
|
describe('assembled world', () => {
|
||||||
|
it('assembles a minimal manifest-backed world', () => {
|
||||||
|
const result = build()
|
||||||
|
expect(result.startingRoom).toBe('foyer')
|
||||||
|
expect(result.startingInventory).toEqual(['letter'])
|
||||||
|
expect(result.endingPriority).toEqual(['true'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a game manifest with an unknown starting room', () => {
|
||||||
|
expect(() => build({
|
||||||
|
game: { ...manifest, startingRoom: 'missing-room' },
|
||||||
|
})).toThrow(/startingRoom references "missing-room"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a game manifest with an unknown starting inventory item', () => {
|
||||||
|
expect(() => build({
|
||||||
|
game: { ...manifest, startingInventory: ['missing-item'] },
|
||||||
|
})).toThrow(/startingInventory references unknown item "missing-item"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a game manifest with an unknown ending priority entry', () => {
|
||||||
|
expect(() => build({
|
||||||
|
game: { ...manifest, endingPriority: ['missing-ending'] },
|
||||||
|
})).toThrow(/endingPriority references "missing-ending"/)
|
||||||
|
})
|
||||||
|
|
||||||
it('contains the authored opening and main-floor rooms', () => {
|
it('contains the authored opening and main-floor rooms', () => {
|
||||||
expect(Object.keys(world.rooms)).toEqual(expect.arrayContaining([
|
expect(Object.keys(world.rooms)).toEqual(expect.arrayContaining([
|
||||||
'outside-gate',
|
'outside-gate',
|
||||||
@@ -88,16 +164,356 @@ describe('assembled world', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('startingRoom is a known room', () => {
|
it('startingRoom is a known room', () => {
|
||||||
|
expect(world.game?.startingRoom).toBe(world.startingRoom)
|
||||||
expect(world.rooms[world.startingRoom]).toBeDefined()
|
expect(world.rooms[world.startingRoom]).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('startingInventory items are known', () => {
|
it('startingInventory items are known', () => {
|
||||||
|
expect(world.game?.startingInventory).toEqual(world.startingInventory)
|
||||||
for (const itemId of world.startingInventory) {
|
for (const itemId of world.startingInventory) {
|
||||||
expect(world.items[itemId]).toBeDefined()
|
expect(world.items[itemId]).toBeDefined()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('game manifest text is loaded from markdown', () => {
|
||||||
|
expect(world.game?.title).toBe('Halfstreet')
|
||||||
|
expect(world.game?.openingArt).toContain('____')
|
||||||
|
expect(world.game?.helpText).toContain('This is a text adventure.')
|
||||||
|
expect(world.game?.endedText).toContain('The story has ended.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parser vocabulary is loaded from markdown', () => {
|
||||||
|
expect(world.parser?.verbs.take).toContain('pick up')
|
||||||
|
expect(world.parser?.verbs.open).toContain('uncover')
|
||||||
|
expect(world.parser?.directions.n).toContain('north')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('UI config is loaded from markdown', () => {
|
||||||
|
expect(world.ui?.pageTitle).toBe('Halfstreet - Ethan J Lewis')
|
||||||
|
expect(world.ui?.footer.links.map((link) => link.label)).toEqual(['GNU 3.0', 'Source Code'])
|
||||||
|
expect(world.ui?.footer.showBuild).toBe(true)
|
||||||
|
expect(world.ui?.features.typedEffect).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('system messages are loaded from markdown', () => {
|
||||||
|
expect(world.messages?.['unknown-verb']).toContain("don't fit this place")
|
||||||
|
expect(world.messages?.taken).toBe('Taken.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('light mechanic config is loaded from markdown', () => {
|
||||||
|
expect(world.mechanics?.light?.enabled).toBe(true)
|
||||||
|
expect(world.mechanics?.light?.maxTurns).toBe(6)
|
||||||
|
expect(world.mechanics?.light?.burnOn).toEqual(['move', 'wait'])
|
||||||
|
expect(world.mechanics?.light?.messages?.flameDies).toBe('The flame dies.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolve mechanic config is loaded from markdown', () => {
|
||||||
|
expect(world.mechanics?.resolve?.enabled).toBe(true)
|
||||||
|
expect(world.mechanics?.resolve?.ladder).toEqual(['steady', 'shaken', 'reeling', 'returning'])
|
||||||
|
expect(world.mechanics?.resolve?.safeRooms.recoverySteps).toBe(1)
|
||||||
|
expect(world.mechanics?.resolve?.failure.afterRetreat).toBe('shaken')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('declarative actions are loaded from markdown', () => {
|
||||||
|
expect(world.actions?.['burn-letter']?.verbs).toEqual(['use'])
|
||||||
|
expect(world.actions?.['burn-letter']?.requires?.allVisibleOrHeld).toEqual(['letter', 'matches'])
|
||||||
|
expect(world.actions?.['burn-letter']?.messages.success).toContain('ash')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handler-backed actions are loaded from markdown', () => {
|
||||||
|
expect(world.actions?.['drink-whiskey']?.verbs).toEqual(['drink'])
|
||||||
|
expect(world.actions?.['drink-whiskey']?.handler).toBe('drunk-transition')
|
||||||
|
expect(world.actions?.['drink-whiskey']?.requires?.allHeld).toEqual(['whiskey'])
|
||||||
|
expect(world.actions?.['drink-whiskey']?.drunkTransition).toEqual({
|
||||||
|
destinationRoom: 'drunk-hall',
|
||||||
|
maxMoves: 20,
|
||||||
|
wakeRoom: 'foyer',
|
||||||
|
resetRoom: 'kitchen',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads a markdown-owned encounter phase machine', () => {
|
||||||
|
expect(world.encounters.rat).toMatchObject({
|
||||||
|
id: 'rat',
|
||||||
|
startsIn: 'cellar-stair',
|
||||||
|
initialPhase: 'lurking',
|
||||||
|
onResolved: { setFlags: { ratGone: true } },
|
||||||
|
defaultWrongVerbNarration: 'The rat watches.',
|
||||||
|
})
|
||||||
|
const lurking = world.encounters.rat?.phases.lurking
|
||||||
|
expect(lurking).toBeDefined()
|
||||||
|
expect(lurking?.transitions).toEqual([
|
||||||
|
{
|
||||||
|
verb: 'attack',
|
||||||
|
target: 'rat',
|
||||||
|
chipLabel: 'ATTACK RAT',
|
||||||
|
chipCommand: 'attack rat',
|
||||||
|
narration: 'You stamp. The rat squeals and is gone into the dark.',
|
||||||
|
to: 'resolved',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
verb: 'wait',
|
||||||
|
chipLabel: 'WAIT',
|
||||||
|
narration: 'The rat does not move. Neither do you.',
|
||||||
|
to: 'lurking',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads the first migrated encounter batch from markdown', () => {
|
||||||
|
expect(world.encounters['window-guest']).toMatchObject({
|
||||||
|
aliases: ['guest', 'window guest', 'curtains', 'curtain', 'window'],
|
||||||
|
onResolved: { setFlags: { curtainsClosed: true } },
|
||||||
|
onFailed: { retreatTo: 'hallway' },
|
||||||
|
})
|
||||||
|
expect(world.encounters['covered-cage']).toMatchObject({
|
||||||
|
aliases: ['covered cage', 'cage', 'birdcage', 'cloth'],
|
||||||
|
onResolved: { setFlags: { cageUncovered: true } },
|
||||||
|
onFailed: { retreatTo: 'hallway' },
|
||||||
|
})
|
||||||
|
expect(world.encounters['piano-echo']).toMatchObject({
|
||||||
|
aliases: ['piano echo', 'piano', 'note', 'key'],
|
||||||
|
onResolved: { setFlags: { musicSolved: true } },
|
||||||
|
onFailed: { retreatTo: 'hallway' },
|
||||||
|
})
|
||||||
|
expect(world.encounters['window-guest']?.phases['standing-outside']?.transitions[0]?.chipCommand).toBe('close curtains')
|
||||||
|
expect(world.encounters['covered-cage']?.phases.rustling?.transitions[0]?.chipCommand).toBe('uncover cage')
|
||||||
|
expect(world.encounters['piano-echo']?.phases.listening?.transitions[0]?.chipCommand).toBe('play note')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads the wait-resolved encounter batch from markdown', () => {
|
||||||
|
expect(world.encounters['breathing-wall']).toMatchObject({
|
||||||
|
aliases: ['breathing wall', 'wall', 'walls', 'breathing'],
|
||||||
|
onResolved: { setFlags: { breathingWallPassed: true } },
|
||||||
|
onFailed: { retreatTo: 'music-room' },
|
||||||
|
})
|
||||||
|
expect(world.encounters['linen-shape']).toMatchObject({
|
||||||
|
aliases: ['linen shape', 'shape', 'sheet', 'sheets', 'linen'],
|
||||||
|
onResolved: { setFlags: { linenShapeEmpty: true } },
|
||||||
|
onFailed: { retreatTo: 'servants-passage' },
|
||||||
|
})
|
||||||
|
expect(world.encounters['stair-sleeper']).toMatchObject({
|
||||||
|
aliases: ['stair sleeper', 'sleeper', 'figure', 'person', 'body'],
|
||||||
|
onResolved: { setFlags: { hallwayShifted: true } },
|
||||||
|
onFailed: { retreatTo: 'parlor' },
|
||||||
|
})
|
||||||
|
expect(world.encounters['breathing-wall']?.phases.audible?.transitions[0]).toMatchObject({ verb: 'wait', chipLabel: 'WAIT', to: 'resolved' })
|
||||||
|
expect(world.encounters['linen-shape']?.phases.hanging?.transitions[0]).toMatchObject({ verb: 'wait', chipLabel: 'WAIT', to: 'resolved' })
|
||||||
|
expect(world.encounters['stair-sleeper']?.phases.seated?.transitions[0]).toMatchObject({ verb: 'wait', chipLabel: 'WAIT', to: 'resolved' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads the item-gated encounter batch from markdown', () => {
|
||||||
|
expect(world.encounters['ivy-figure']).toMatchObject({
|
||||||
|
aliases: ['ivy figure', 'figure', 'ivy', 'vines', 'vine'],
|
||||||
|
onResolved: { setFlags: { conservatoryVinesCut: true } },
|
||||||
|
onFailed: { retreatTo: 'dining-room' },
|
||||||
|
})
|
||||||
|
expect(world.encounters['child-beneath-well']).toMatchObject({
|
||||||
|
aliases: ['child', 'well child', 'child beneath well', 'barefoot child'],
|
||||||
|
onResolved: { setFlags: { childPassedWell: true } },
|
||||||
|
onFailed: { retreatTo: 'well' },
|
||||||
|
})
|
||||||
|
expect(world.encounters['bone-keeper']).toMatchObject({
|
||||||
|
aliases: ['bone keeper', 'keeper', 'hands', 'bones', 'ribs'],
|
||||||
|
onResolved: { setFlags: { burialRingPlaced: true } },
|
||||||
|
onFailed: { retreatTo: 'tunnel' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(world.encounters['ivy-figure']?.phases.hidden?.transitions).toEqual([
|
||||||
|
expect.objectContaining({ verb: 'cut', requires: { item: 'pruning-shears' }, chipCommand: 'cut vines' }),
|
||||||
|
expect.objectContaining({ verb: 'use', requires: { item: 'pruning-shears' }, chipCommand: 'use vines with shears' }),
|
||||||
|
])
|
||||||
|
expect(world.encounters['child-beneath-well']?.phases.climbing?.transitions).toEqual([
|
||||||
|
expect.objectContaining({ verb: 'hold', requires: { item: 'toy-dog' }, setFlags: { woofReturned: true }, chipCommand: 'hold dog' }),
|
||||||
|
expect.objectContaining({ verb: 'wait', chipLabel: 'WAIT', to: 'resolved' }),
|
||||||
|
])
|
||||||
|
expect(world.encounters['bone-keeper']?.phases.arranging?.transitions[0]).toMatchObject({
|
||||||
|
verb: 'drop',
|
||||||
|
target: 'burial-ring',
|
||||||
|
requires: { item: 'burial-ring' },
|
||||||
|
chipCommand: 'leave ring',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads the garden and lower-passage encounter batch from markdown', () => {
|
||||||
|
expect(world.encounters['garden-procession']).toMatchObject({
|
||||||
|
aliases: ['garden procession', 'procession', 'lanterns', 'lantern', 'lights', 'hedge'],
|
||||||
|
onResolved: { setFlags: { gardenQuiet: true } },
|
||||||
|
onFailed: { retreatTo: 'back-door' },
|
||||||
|
})
|
||||||
|
expect(world.encounters.reflection).toMatchObject({
|
||||||
|
aliases: ['reflection', 'water', 'black water', 'face', 'reflected figure'],
|
||||||
|
onResolved: { setFlags: { reflectionObscured: true } },
|
||||||
|
onFailed: { retreatTo: 'ossuary' },
|
||||||
|
})
|
||||||
|
expect(world.encounters['root-movement']).toMatchObject({
|
||||||
|
aliases: ['root movement', 'roots', 'root', 'opening'],
|
||||||
|
onResolved: { setFlags: { rootsListenedTo: true } },
|
||||||
|
onFailed: { retreatTo: 'flooded-passage' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(world.encounters['garden-procession']?.phases.passing?.transitions[0]).toMatchObject({
|
||||||
|
verb: 'wait',
|
||||||
|
chipLabel: 'WAIT',
|
||||||
|
to: 'resolved',
|
||||||
|
})
|
||||||
|
expect(world.encounters.reflection?.phases.following?.transitions[0]).toMatchObject({
|
||||||
|
verb: 'use',
|
||||||
|
target: 'reflection',
|
||||||
|
requires: { item: 'damp-sheet' },
|
||||||
|
chipCommand: 'use water with sheet',
|
||||||
|
})
|
||||||
|
expect(world.encounters['root-movement']?.phases.shifting?.transitions[0]).toMatchObject({
|
||||||
|
verb: 'listen',
|
||||||
|
chipCommand: 'listen',
|
||||||
|
to: 'resolved',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads the final encounter batch from markdown', () => {
|
||||||
|
expect(world.encounters['portrait-woman']).toMatchObject({
|
||||||
|
aliases: ['portrait woman', 'woman', 'portrait', 'portraits', 'veil', 'funeral veil'],
|
||||||
|
onResolved: { setFlags: { familyResemblanceSeen: true } },
|
||||||
|
onFailed: { retreatTo: 'root-chamber' },
|
||||||
|
})
|
||||||
|
expect(world.encounters.basilisk).toMatchObject({
|
||||||
|
aliases: ['basilisk', 'creature', 'eye', 'altar', 'coil'],
|
||||||
|
onResolved: { setFlags: { basiliskSpared: true } },
|
||||||
|
onFailed: { retreatTo: 'vault' },
|
||||||
|
})
|
||||||
|
expect(world.encounters['vault-memory']).toMatchObject({
|
||||||
|
aliases: ['vault memory', 'memory', 'bed', 'photograph', 'photo', 'thing', 'buried thing'],
|
||||||
|
})
|
||||||
|
expect(world.encounters['creaking-floorboard']).toMatchObject({
|
||||||
|
aliases: ['creaking floorboard', 'floorboard', 'board', 'creak', 'secret door', 'faceless man', 'man', 'voice'],
|
||||||
|
})
|
||||||
|
expect(world.encounters['distant-steps']).toMatchObject({
|
||||||
|
aliases: ['distant steps', 'steps', 'footsteps', 'hallway'],
|
||||||
|
onResolved: { setFlags: { distantStepsPassed: true } },
|
||||||
|
onFailed: { retreatTo: 'parlor' },
|
||||||
|
})
|
||||||
|
expect(world.encounters['rainwater-basin']).toMatchObject({
|
||||||
|
aliases: ['rainwater basin', 'basin', 'water', 'rainwater', 'reflection'],
|
||||||
|
onResolved: { setFlags: { rainRoomEntered: true, houseAcceptedYou: true } },
|
||||||
|
onFailed: { retreatTo: 'wrong-hallway' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(world.encounters['portrait-woman']?.phases.watching?.transitions[0]).toMatchObject({
|
||||||
|
verb: 'examine',
|
||||||
|
chipCommand: 'examine portraits',
|
||||||
|
})
|
||||||
|
expect(world.encounters.basilisk?.phases.sleeping?.transitions).toEqual([
|
||||||
|
expect.objectContaining({ verb: 'pour', requires: { item: 'silver-vial' }, chipCommand: 'pour vial on basilisk' }),
|
||||||
|
expect.objectContaining({ verb: 'use', requires: { item: 'silver-vial' }, chipCommand: 'use basilisk with vial' }),
|
||||||
|
])
|
||||||
|
expect(world.encounters['vault-memory']?.phases.buried?.transitions).toEqual([
|
||||||
|
expect.objectContaining({ verb: 'read', requires: { item: 'family-register' }, setFlags: { nameSpoken: true } }),
|
||||||
|
expect.objectContaining({ verb: 'take', setFlags: { tookPhotograph: true } }),
|
||||||
|
expect.objectContaining({ verb: 'attack', setFlags: { disturbedVault: true } }),
|
||||||
|
])
|
||||||
|
expect(world.encounters['creaking-floorboard']?.phases.creaking?.transitions).toEqual([
|
||||||
|
expect.objectContaining({ verb: 'listen', setFlags: { drunkSecretFound: true, facelessManMet: true, houseDebtNamed: true } }),
|
||||||
|
expect.objectContaining({ verb: 'open', setFlags: { drunkSecretFound: true, facelessManMet: true, houseDebtNamed: true } }),
|
||||||
|
])
|
||||||
|
expect(world.encounters['distant-steps']?.phases.approaching?.transitions[0]).toMatchObject({
|
||||||
|
verb: 'wait',
|
||||||
|
to: 'resolved',
|
||||||
|
})
|
||||||
|
expect(world.encounters['rainwater-basin']?.phases.reflecting?.transitions).toEqual([
|
||||||
|
expect.objectContaining({ verb: 'look', chipCommand: 'look basin' }),
|
||||||
|
expect.objectContaining({ verb: 'examine', chipCommand: 'examine basin' }),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects duplicate handler-backed action owners', () => {
|
||||||
|
expect(() => build({
|
||||||
|
actions: {
|
||||||
|
first: {
|
||||||
|
id: 'first',
|
||||||
|
verbs: ['drink'],
|
||||||
|
handler: 'drunk-transition',
|
||||||
|
requires: { allHeld: ['letter'] },
|
||||||
|
drunkTransition: {
|
||||||
|
destinationRoom: 'foyer',
|
||||||
|
maxMoves: 1,
|
||||||
|
wakeRoom: 'foyer',
|
||||||
|
resetRoom: 'foyer',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
success: 'ok',
|
||||||
|
secretFoundPassOut: 'secret',
|
||||||
|
tooManyMovesPassOut: 'moves',
|
||||||
|
reset: 'reset',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
second: {
|
||||||
|
id: 'second',
|
||||||
|
verbs: ['drink'],
|
||||||
|
handler: 'drunk-transition',
|
||||||
|
requires: { allHeld: ['letter'] },
|
||||||
|
drunkTransition: {
|
||||||
|
destinationRoom: 'foyer',
|
||||||
|
maxMoves: 1,
|
||||||
|
wakeRoom: 'foyer',
|
||||||
|
resetRoom: 'foyer',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
success: 'ok',
|
||||||
|
secretFoundPassOut: 'secret',
|
||||||
|
tooManyMovesPassOut: 'moves',
|
||||||
|
reset: 'reset',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})).toThrow(/handler "drunk-transition" is already used by actions\/first\.md/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports the action field that references an unknown item', () => {
|
||||||
|
expect(() => build({
|
||||||
|
actions: {
|
||||||
|
burn: {
|
||||||
|
id: 'burn',
|
||||||
|
verbs: ['use'],
|
||||||
|
requires: { allVisibleOrHeld: ['letter', 'missing-match'] },
|
||||||
|
messages: { success: 'ok' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})).toThrow(/actions\/burn\.md: requires\.allVisibleOrHeld references unknown item "missing-match"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports the drunk transition field that references an unknown room', () => {
|
||||||
|
expect(() => build({
|
||||||
|
actions: {
|
||||||
|
drink: {
|
||||||
|
id: 'drink',
|
||||||
|
verbs: ['drink'],
|
||||||
|
handler: 'drunk-transition',
|
||||||
|
requires: { allHeld: ['letter'] },
|
||||||
|
drunkTransition: {
|
||||||
|
destinationRoom: 'missing-room',
|
||||||
|
maxMoves: 1,
|
||||||
|
wakeRoom: 'foyer',
|
||||||
|
resetRoom: 'foyer',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
success: 'ok',
|
||||||
|
secretFoundPassOut: 'secret',
|
||||||
|
tooManyMovesPassOut: 'moves',
|
||||||
|
reset: 'reset',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})).toThrow(/actions\/drink\.md: drunkTransition\.destinationRoom references unknown room "missing-room"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ending priority references loaded endings', () => {
|
||||||
|
expect(world.endingPriority).toEqual(world.game?.endingPriority)
|
||||||
|
for (const endingId of world.endingPriority ?? []) {
|
||||||
|
expect(world.endings[endingId], endingId).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('endings have non-empty narration where the original did', () => {
|
it('endings have non-empty narration where the original did', () => {
|
||||||
expect(world.endings.true.narration.length).toBeGreaterThan(0)
|
expect(world.endings['true']?.narration.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,517 +0,0 @@
|
|||||||
import type { EncounterDef } from './types'
|
|
||||||
import { narration } from './loader'
|
|
||||||
|
|
||||||
export const encounters: Record<string, EncounterDef> = {
|
|
||||||
rat: {
|
|
||||||
id: 'rat',
|
|
||||||
startsIn: 'cellar-stair',
|
|
||||||
initialPhase: 'lurking',
|
|
||||||
phases: {
|
|
||||||
lurking: {
|
|
||||||
description: narration('rat', 'lurking'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'attack',
|
|
||||||
target: 'rat',
|
|
||||||
chipLabel: 'ATTACK RAT',
|
|
||||||
chipCommand: 'attack rat',
|
|
||||||
narration: narration('rat', 'attack-rat-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
verb: 'wait',
|
|
||||||
chipLabel: 'WAIT',
|
|
||||||
narration: narration('rat', 'wait-stays'),
|
|
||||||
to: 'lurking',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { ratGone: true } },
|
|
||||||
defaultWrongVerbNarration: 'The rat watches.',
|
|
||||||
},
|
|
||||||
'window-guest': {
|
|
||||||
id: 'window-guest',
|
|
||||||
aliases: ['guest', 'window guest', 'curtains', 'curtain', 'window'],
|
|
||||||
startsIn: 'dining-room',
|
|
||||||
initialPhase: 'standing-outside',
|
|
||||||
phases: {
|
|
||||||
'standing-outside': {
|
|
||||||
description: narration('window-guest', 'standing-outside'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'close',
|
|
||||||
target: 'window-guest',
|
|
||||||
chipLabel: 'CLOSE CURTAINS',
|
|
||||||
chipCommand: 'close curtains',
|
|
||||||
narration: narration('window-guest', 'close-window-guest-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { curtainsClosed: true } },
|
|
||||||
onFailed: { narration: narration('window-guest', 'failed'), retreatTo: 'hallway' },
|
|
||||||
defaultWrongVerbNarration: narration('window-guest', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'ivy-figure': {
|
|
||||||
id: 'ivy-figure',
|
|
||||||
aliases: ['ivy figure', 'figure', 'ivy', 'vines', 'vine'],
|
|
||||||
startsIn: 'conservatory',
|
|
||||||
initialPhase: 'hidden',
|
|
||||||
phases: {
|
|
||||||
hidden: {
|
|
||||||
description: narration('ivy-figure', 'hidden'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'cut',
|
|
||||||
target: 'ivy-figure',
|
|
||||||
chipLabel: 'CUT VINES',
|
|
||||||
chipCommand: 'cut vines',
|
|
||||||
requires: { item: 'pruning-shears' },
|
|
||||||
narration: narration('ivy-figure', 'cut-ivy-figure-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
verb: 'use',
|
|
||||||
target: 'ivy-figure',
|
|
||||||
chipLabel: 'USE SHEARS',
|
|
||||||
chipCommand: 'use vines with shears',
|
|
||||||
requires: { item: 'pruning-shears' },
|
|
||||||
narration: narration('ivy-figure', 'cut-ivy-figure-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { conservatoryVinesCut: true } },
|
|
||||||
onFailed: { narration: narration('ivy-figure', 'failed'), retreatTo: 'dining-room' },
|
|
||||||
defaultWrongVerbNarration: narration('ivy-figure', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'covered-cage': {
|
|
||||||
id: 'covered-cage',
|
|
||||||
aliases: ['covered cage', 'cage', 'birdcage', 'cloth'],
|
|
||||||
startsIn: 'smoking-room',
|
|
||||||
initialPhase: 'rustling',
|
|
||||||
phases: {
|
|
||||||
rustling: {
|
|
||||||
description: narration('covered-cage', 'rustling'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'open',
|
|
||||||
target: 'covered-cage',
|
|
||||||
chipLabel: 'UNCOVER CAGE',
|
|
||||||
chipCommand: 'uncover cage',
|
|
||||||
narration: narration('covered-cage', 'open-covered-cage-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { cageUncovered: true } },
|
|
||||||
onFailed: { narration: narration('covered-cage', 'failed'), retreatTo: 'hallway' },
|
|
||||||
defaultWrongVerbNarration: narration('covered-cage', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'piano-echo': {
|
|
||||||
id: 'piano-echo',
|
|
||||||
aliases: ['piano echo', 'piano', 'note', 'key'],
|
|
||||||
startsIn: 'music-room',
|
|
||||||
initialPhase: 'listening',
|
|
||||||
phases: {
|
|
||||||
listening: {
|
|
||||||
description: narration('piano-echo', 'listening'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'play',
|
|
||||||
target: 'piano-echo',
|
|
||||||
chipLabel: 'PLAY NOTE',
|
|
||||||
chipCommand: 'play note',
|
|
||||||
narration: narration('piano-echo', 'play-piano-echo-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { musicSolved: true } },
|
|
||||||
onFailed: { narration: narration('piano-echo', 'failed'), retreatTo: 'hallway' },
|
|
||||||
defaultWrongVerbNarration: narration('piano-echo', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'breathing-wall': {
|
|
||||||
id: 'breathing-wall',
|
|
||||||
aliases: ['breathing wall', 'wall', 'walls', 'breathing'],
|
|
||||||
startsIn: 'servants-passage',
|
|
||||||
initialPhase: 'audible',
|
|
||||||
phases: {
|
|
||||||
audible: {
|
|
||||||
description: narration('breathing-wall', 'audible'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'wait',
|
|
||||||
chipLabel: 'WAIT',
|
|
||||||
narration: narration('breathing-wall', 'wait-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { breathingWallPassed: true } },
|
|
||||||
onFailed: { narration: narration('breathing-wall', 'failed'), retreatTo: 'music-room' },
|
|
||||||
defaultWrongVerbNarration: narration('breathing-wall', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'linen-shape': {
|
|
||||||
id: 'linen-shape',
|
|
||||||
aliases: ['linen shape', 'shape', 'sheet', 'sheets', 'linen'],
|
|
||||||
startsIn: 'laundry',
|
|
||||||
initialPhase: 'hanging',
|
|
||||||
phases: {
|
|
||||||
hanging: {
|
|
||||||
description: narration('linen-shape', 'hanging'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'wait',
|
|
||||||
chipLabel: 'WAIT',
|
|
||||||
narration: narration('linen-shape', 'wait-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { linenShapeEmpty: true } },
|
|
||||||
onFailed: { narration: narration('linen-shape', 'failed'), retreatTo: 'servants-passage' },
|
|
||||||
defaultWrongVerbNarration: narration('linen-shape', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'stair-sleeper': {
|
|
||||||
id: 'stair-sleeper',
|
|
||||||
aliases: ['stair sleeper', 'sleeper', 'figure', 'person', 'body'],
|
|
||||||
startsIn: 'stair-up',
|
|
||||||
initialPhase: 'seated',
|
|
||||||
phases: {
|
|
||||||
seated: {
|
|
||||||
description: narration('stair-sleeper', 'seated'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'wait',
|
|
||||||
chipLabel: 'WAIT',
|
|
||||||
narration: narration('stair-sleeper', 'wait-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { hallwayShifted: true } },
|
|
||||||
onFailed: { narration: narration('stair-sleeper', 'failed'), retreatTo: 'parlor' },
|
|
||||||
defaultWrongVerbNarration: narration('stair-sleeper', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'garden-procession': {
|
|
||||||
id: 'garden-procession',
|
|
||||||
aliases: ['garden procession', 'procession', 'lanterns', 'lantern', 'lights', 'hedge'],
|
|
||||||
startsIn: 'garden',
|
|
||||||
initialPhase: 'passing',
|
|
||||||
phases: {
|
|
||||||
passing: {
|
|
||||||
description: narration('garden-procession', 'passing'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'wait',
|
|
||||||
chipLabel: 'WAIT',
|
|
||||||
narration: narration('garden-procession', 'wait-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { gardenQuiet: true } },
|
|
||||||
onFailed: { narration: narration('garden-procession', 'failed'), retreatTo: 'back-door' },
|
|
||||||
defaultWrongVerbNarration: narration('garden-procession', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'child-beneath-well': {
|
|
||||||
id: 'child-beneath-well',
|
|
||||||
aliases: ['child', 'well child', 'child beneath well', 'barefoot child'],
|
|
||||||
startsIn: 'well-shaft',
|
|
||||||
initialPhase: 'climbing',
|
|
||||||
phases: {
|
|
||||||
climbing: {
|
|
||||||
description: narration('child-beneath-well', 'climbing'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'hold',
|
|
||||||
target: 'toy-dog',
|
|
||||||
chipLabel: 'SHOW DOG',
|
|
||||||
chipCommand: 'hold dog',
|
|
||||||
requires: { item: 'toy-dog' },
|
|
||||||
narration: narration('child-beneath-well', 'hold-toy-dog-resolved'),
|
|
||||||
setFlags: { woofReturned: true },
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
verb: 'wait',
|
|
||||||
chipLabel: 'WAIT',
|
|
||||||
narration: narration('child-beneath-well', 'wait-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { childPassedWell: true } },
|
|
||||||
onFailed: { narration: narration('child-beneath-well', 'failed'), retreatTo: 'well' },
|
|
||||||
defaultWrongVerbNarration: narration('child-beneath-well', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'bone-keeper': {
|
|
||||||
id: 'bone-keeper',
|
|
||||||
aliases: ['bone keeper', 'keeper', 'hands', 'bones', 'ribs'],
|
|
||||||
startsIn: 'ossuary',
|
|
||||||
initialPhase: 'arranging',
|
|
||||||
phases: {
|
|
||||||
arranging: {
|
|
||||||
description: narration('bone-keeper', 'arranging'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'drop',
|
|
||||||
target: 'burial-ring',
|
|
||||||
chipLabel: 'LEAVE RING',
|
|
||||||
chipCommand: 'leave ring',
|
|
||||||
requires: { item: 'burial-ring' },
|
|
||||||
narration: narration('bone-keeper', 'leave-burial-ring-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { burialRingPlaced: true } },
|
|
||||||
onFailed: { narration: narration('bone-keeper', 'failed'), retreatTo: 'tunnel' },
|
|
||||||
defaultWrongVerbNarration: narration('bone-keeper', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
reflection: {
|
|
||||||
id: 'reflection',
|
|
||||||
aliases: ['reflection', 'water', 'black water', 'face', 'reflected figure'],
|
|
||||||
startsIn: 'flooded-passage',
|
|
||||||
initialPhase: 'following',
|
|
||||||
phases: {
|
|
||||||
following: {
|
|
||||||
description: narration('reflection', 'following'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'use',
|
|
||||||
target: 'reflection',
|
|
||||||
chipLabel: 'USE SHEET',
|
|
||||||
chipCommand: 'use water with sheet',
|
|
||||||
requires: { item: 'damp-sheet' },
|
|
||||||
narration: narration('reflection', 'obscure-water-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { reflectionObscured: true } },
|
|
||||||
onFailed: { narration: narration('reflection', 'failed'), retreatTo: 'ossuary' },
|
|
||||||
defaultWrongVerbNarration: narration('reflection', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'root-movement': {
|
|
||||||
id: 'root-movement',
|
|
||||||
aliases: ['root movement', 'roots', 'root', 'opening'],
|
|
||||||
startsIn: 'root-chamber',
|
|
||||||
initialPhase: 'shifting',
|
|
||||||
phases: {
|
|
||||||
shifting: {
|
|
||||||
description: narration('root-movement', 'shifting'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'listen',
|
|
||||||
chipLabel: 'LISTEN',
|
|
||||||
chipCommand: 'listen',
|
|
||||||
narration: narration('root-movement', 'listen-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { rootsListenedTo: true } },
|
|
||||||
onFailed: { narration: narration('root-movement', 'failed'), retreatTo: 'flooded-passage' },
|
|
||||||
defaultWrongVerbNarration: narration('root-movement', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'portrait-woman': {
|
|
||||||
id: 'portrait-woman',
|
|
||||||
aliases: ['portrait woman', 'woman', 'portrait', 'portraits', 'veil', 'funeral veil'],
|
|
||||||
startsIn: 'burial-gallery',
|
|
||||||
initialPhase: 'watching',
|
|
||||||
phases: {
|
|
||||||
watching: {
|
|
||||||
description: narration('portrait-woman', 'watching'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'examine',
|
|
||||||
target: 'portrait-woman',
|
|
||||||
chipLabel: 'EXAMINE PORTRAITS',
|
|
||||||
chipCommand: 'examine portraits',
|
|
||||||
narration: narration('portrait-woman', 'examine-portraits-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { familyResemblanceSeen: true } },
|
|
||||||
onFailed: { narration: narration('portrait-woman', 'failed'), retreatTo: 'root-chamber' },
|
|
||||||
defaultWrongVerbNarration: narration('portrait-woman', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
basilisk: {
|
|
||||||
id: 'basilisk',
|
|
||||||
aliases: ['basilisk', 'creature', 'eye', 'altar', 'coil'],
|
|
||||||
startsIn: 'chapel',
|
|
||||||
initialPhase: 'sleeping',
|
|
||||||
phases: {
|
|
||||||
sleeping: {
|
|
||||||
description: narration('basilisk', 'sleeping'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'pour',
|
|
||||||
target: 'silver-vial',
|
|
||||||
chipLabel: 'POUR VIAL',
|
|
||||||
chipCommand: 'pour vial on basilisk',
|
|
||||||
requires: { item: 'silver-vial' },
|
|
||||||
narration: narration('basilisk', 'pour-vial-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
verb: 'use',
|
|
||||||
target: 'basilisk',
|
|
||||||
chipLabel: 'USE VIAL',
|
|
||||||
chipCommand: 'use basilisk with vial',
|
|
||||||
requires: { item: 'silver-vial' },
|
|
||||||
narration: narration('basilisk', 'pour-vial-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { basiliskSpared: true } },
|
|
||||||
onFailed: { narration: narration('basilisk', 'failed'), retreatTo: 'vault' },
|
|
||||||
defaultWrongVerbNarration: narration('basilisk', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'vault-memory': {
|
|
||||||
id: 'vault-memory',
|
|
||||||
aliases: ['vault memory', 'memory', 'bed', 'photograph', 'photo', 'thing', 'buried thing'],
|
|
||||||
startsIn: 'vault',
|
|
||||||
initialPhase: 'buried',
|
|
||||||
phases: {
|
|
||||||
buried: {
|
|
||||||
description: narration('vault-memory', 'buried'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'read',
|
|
||||||
target: 'family-register',
|
|
||||||
chipLabel: 'READ REGISTER',
|
|
||||||
chipCommand: 'read register',
|
|
||||||
requires: { item: 'family-register' },
|
|
||||||
narration: narration('vault-memory', 'read-register-resolved'),
|
|
||||||
setFlags: { nameSpoken: true },
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
verb: 'take',
|
|
||||||
target: 'vault-memory',
|
|
||||||
chipLabel: 'TAKE PHOTO',
|
|
||||||
chipCommand: 'take photograph',
|
|
||||||
narration: narration('vault-memory', 'take-photograph-resolved'),
|
|
||||||
setFlags: { tookPhotograph: true },
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
verb: 'attack',
|
|
||||||
target: 'vault-memory',
|
|
||||||
chipLabel: 'ATTACK BED',
|
|
||||||
chipCommand: 'attack bed',
|
|
||||||
narration: narration('vault-memory', 'attack-bed-resolved'),
|
|
||||||
setFlags: { disturbedVault: true },
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultWrongVerbNarration: narration('vault-memory', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'creaking-floorboard': {
|
|
||||||
id: 'creaking-floorboard',
|
|
||||||
aliases: ['creaking floorboard', 'floorboard', 'board', 'creak', 'secret door', 'faceless man', 'man', 'voice'],
|
|
||||||
startsIn: 'drunk-landing',
|
|
||||||
initialPhase: 'creaking',
|
|
||||||
phases: {
|
|
||||||
creaking: {
|
|
||||||
description: narration('creaking-floorboard', 'creaking'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'listen',
|
|
||||||
chipLabel: 'LISTEN',
|
|
||||||
chipCommand: 'listen',
|
|
||||||
narration: narration('creaking-floorboard', 'listen-resolved'),
|
|
||||||
setFlags: { drunkSecretFound: true, facelessManMet: true, houseDebtNamed: true },
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
verb: 'open',
|
|
||||||
target: 'creaking-floorboard',
|
|
||||||
chipLabel: 'OPEN BOARD',
|
|
||||||
chipCommand: 'open floorboard',
|
|
||||||
narration: narration('creaking-floorboard', 'listen-resolved'),
|
|
||||||
setFlags: { drunkSecretFound: true, facelessManMet: true, houseDebtNamed: true },
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultWrongVerbNarration: narration('creaking-floorboard', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'distant-steps': {
|
|
||||||
id: 'distant-steps',
|
|
||||||
aliases: ['distant steps', 'steps', 'footsteps', 'hallway'],
|
|
||||||
startsIn: 'wrong-hallway',
|
|
||||||
initialPhase: 'approaching',
|
|
||||||
phases: {
|
|
||||||
approaching: {
|
|
||||||
description: narration('distant-steps', 'approaching'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'wait',
|
|
||||||
chipLabel: 'WAIT',
|
|
||||||
narration: narration('distant-steps', 'wait-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { distantStepsPassed: true } },
|
|
||||||
onFailed: { narration: narration('distant-steps', 'failed'), retreatTo: 'parlor' },
|
|
||||||
defaultWrongVerbNarration: narration('distant-steps', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
'rainwater-basin': {
|
|
||||||
id: 'rainwater-basin',
|
|
||||||
aliases: ['rainwater basin', 'basin', 'water', 'rainwater', 'reflection'],
|
|
||||||
startsIn: 'rain-room',
|
|
||||||
initialPhase: 'reflecting',
|
|
||||||
phases: {
|
|
||||||
reflecting: {
|
|
||||||
description: narration('rainwater-basin', 'reflecting'),
|
|
||||||
transitions: [
|
|
||||||
{
|
|
||||||
verb: 'look',
|
|
||||||
target: 'rainwater-basin',
|
|
||||||
chipLabel: 'LOOK BASIN',
|
|
||||||
chipCommand: 'look basin',
|
|
||||||
narration: narration('rainwater-basin', 'look-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
verb: 'examine',
|
|
||||||
target: 'rainwater-basin',
|
|
||||||
chipLabel: 'EXAMINE BASIN',
|
|
||||||
chipCommand: 'examine basin',
|
|
||||||
narration: narration('rainwater-basin', 'look-resolved'),
|
|
||||||
to: 'resolved',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onResolved: { setFlags: { rainRoomEntered: true, houseAcceptedYou: true } },
|
|
||||||
onFailed: { narration: narration('rainwater-basin', 'failed'), retreatTo: 'wrong-hallway' },
|
|
||||||
defaultWrongVerbNarration: narration('rainwater-basin', 'wrong-verb'),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,34 @@
|
|||||||
id: basilisk
|
id: basilisk
|
||||||
startsIn: "[[chapel]]"
|
startsIn: "[[chapel]]"
|
||||||
initialPhase: sleeping
|
initialPhase: sleeping
|
||||||
|
aliases: [basilisk, creature, eye, altar, coil]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
basiliskSpared: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[vault]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
sleeping:
|
||||||
|
description: sleeping
|
||||||
|
transitions:
|
||||||
|
- verb: pour
|
||||||
|
target: silver-vial
|
||||||
|
chipLabel: POUR VIAL
|
||||||
|
chipCommand: pour vial on basilisk
|
||||||
|
requires:
|
||||||
|
item: "[[silver-vial]]"
|
||||||
|
narration: pour-vial-resolved
|
||||||
|
to: resolved
|
||||||
|
- verb: use
|
||||||
|
target: basilisk
|
||||||
|
chipLabel: USE VIAL
|
||||||
|
chipCommand: use basilisk with vial
|
||||||
|
requires:
|
||||||
|
item: "[[silver-vial]]"
|
||||||
|
narration: pour-vial-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## sleeping
|
## sleeping
|
||||||
|
|||||||
@@ -2,6 +2,26 @@
|
|||||||
id: bone-keeper
|
id: bone-keeper
|
||||||
startsIn: "[[ossuary]]"
|
startsIn: "[[ossuary]]"
|
||||||
initialPhase: arranging
|
initialPhase: arranging
|
||||||
|
aliases: [bone keeper, keeper, hands, bones, ribs]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
burialRingPlaced: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[tunnel]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
arranging:
|
||||||
|
description: arranging
|
||||||
|
transitions:
|
||||||
|
- verb: drop
|
||||||
|
target: "[[burial-ring]]"
|
||||||
|
chipLabel: LEAVE RING
|
||||||
|
chipCommand: leave ring
|
||||||
|
requires:
|
||||||
|
item: "[[burial-ring]]"
|
||||||
|
narration: leave-burial-ring-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## arranging
|
## arranging
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
id: breathing-wall
|
id: breathing-wall
|
||||||
startsIn: "[[servants-passage]]"
|
startsIn: "[[servants-passage]]"
|
||||||
initialPhase: audible
|
initialPhase: audible
|
||||||
|
aliases: [breathing wall, wall, walls, breathing]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
breathingWallPassed: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[music-room]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
audible:
|
||||||
|
description: audible
|
||||||
|
transitions:
|
||||||
|
- verb: wait
|
||||||
|
chipLabel: WAIT
|
||||||
|
narration: wait-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## audible
|
## audible
|
||||||
|
|||||||
@@ -2,6 +2,32 @@
|
|||||||
id: child-beneath-well
|
id: child-beneath-well
|
||||||
startsIn: "[[well-shaft]]"
|
startsIn: "[[well-shaft]]"
|
||||||
initialPhase: climbing
|
initialPhase: climbing
|
||||||
|
aliases: [child, well child, child beneath well, barefoot child]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
childPassedWell: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[well]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
climbing:
|
||||||
|
description: climbing
|
||||||
|
transitions:
|
||||||
|
- verb: hold
|
||||||
|
target: "[[toy-dog]]"
|
||||||
|
chipLabel: SHOW DOG
|
||||||
|
chipCommand: hold dog
|
||||||
|
requires:
|
||||||
|
item: "[[toy-dog]]"
|
||||||
|
narration: hold-toy-dog-resolved
|
||||||
|
setFlags:
|
||||||
|
woofReturned: true
|
||||||
|
to: resolved
|
||||||
|
- verb: wait
|
||||||
|
chipLabel: WAIT
|
||||||
|
narration: wait-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## climbing
|
## climbing
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
id: covered-cage
|
id: covered-cage
|
||||||
startsIn: "[[smoking-room]]"
|
startsIn: "[[smoking-room]]"
|
||||||
initialPhase: rustling
|
initialPhase: rustling
|
||||||
|
aliases: [covered cage, cage, birdcage, cloth]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
cageUncovered: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[hallway]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
rustling:
|
||||||
|
description: rustling
|
||||||
|
transitions:
|
||||||
|
- verb: open
|
||||||
|
target: covered-cage
|
||||||
|
chipLabel: UNCOVER CAGE
|
||||||
|
chipCommand: uncover cage
|
||||||
|
narration: open-covered-cage-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## rustling
|
## rustling
|
||||||
|
|||||||
@@ -2,6 +2,31 @@
|
|||||||
id: creaking-floorboard
|
id: creaking-floorboard
|
||||||
startsIn: "[[drunk-landing]]"
|
startsIn: "[[drunk-landing]]"
|
||||||
initialPhase: creaking
|
initialPhase: creaking
|
||||||
|
aliases: [creaking floorboard, floorboard, board, creak, secret door, faceless man, man, voice]
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
creaking:
|
||||||
|
description: creaking
|
||||||
|
transitions:
|
||||||
|
- verb: listen
|
||||||
|
chipLabel: LISTEN
|
||||||
|
chipCommand: listen
|
||||||
|
narration: listen-resolved
|
||||||
|
setFlags:
|
||||||
|
drunkSecretFound: true
|
||||||
|
facelessManMet: true
|
||||||
|
houseDebtNamed: true
|
||||||
|
to: resolved
|
||||||
|
- verb: open
|
||||||
|
target: creaking-floorboard
|
||||||
|
chipLabel: OPEN BOARD
|
||||||
|
chipCommand: open floorboard
|
||||||
|
narration: listen-resolved
|
||||||
|
setFlags:
|
||||||
|
drunkSecretFound: true
|
||||||
|
facelessManMet: true
|
||||||
|
houseDebtNamed: true
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## creaking
|
## creaking
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
id: distant-steps
|
id: distant-steps
|
||||||
startsIn: "[[wrong-hallway]]"
|
startsIn: "[[wrong-hallway]]"
|
||||||
initialPhase: approaching
|
initialPhase: approaching
|
||||||
|
aliases: [distant steps, steps, footsteps, hallway]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
distantStepsPassed: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[parlor]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
approaching:
|
||||||
|
description: approaching
|
||||||
|
transitions:
|
||||||
|
- verb: wait
|
||||||
|
chipLabel: WAIT
|
||||||
|
narration: wait-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## approaching
|
## approaching
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
id: garden-procession
|
id: garden-procession
|
||||||
startsIn: "[[garden]]"
|
startsIn: "[[garden]]"
|
||||||
initialPhase: passing
|
initialPhase: passing
|
||||||
|
aliases: [garden procession, procession, lanterns, lantern, lights, hedge]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
gardenQuiet: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[back-door]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
passing:
|
||||||
|
description: passing
|
||||||
|
transitions:
|
||||||
|
- verb: wait
|
||||||
|
chipLabel: WAIT
|
||||||
|
narration: wait-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## passing
|
## passing
|
||||||
|
|||||||
@@ -2,6 +2,34 @@
|
|||||||
id: ivy-figure
|
id: ivy-figure
|
||||||
startsIn: "[[conservatory]]"
|
startsIn: "[[conservatory]]"
|
||||||
initialPhase: hidden
|
initialPhase: hidden
|
||||||
|
aliases: [ivy figure, figure, ivy, vines, vine]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
conservatoryVinesCut: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[dining-room]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
hidden:
|
||||||
|
description: hidden
|
||||||
|
transitions:
|
||||||
|
- verb: cut
|
||||||
|
target: ivy-figure
|
||||||
|
chipLabel: CUT VINES
|
||||||
|
chipCommand: cut vines
|
||||||
|
requires:
|
||||||
|
item: "[[pruning-shears]]"
|
||||||
|
narration: cut-ivy-figure-resolved
|
||||||
|
to: resolved
|
||||||
|
- verb: use
|
||||||
|
target: ivy-figure
|
||||||
|
chipLabel: USE SHEARS
|
||||||
|
chipCommand: use vines with shears
|
||||||
|
requires:
|
||||||
|
item: "[[pruning-shears]]"
|
||||||
|
narration: cut-ivy-figure-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## hidden
|
## hidden
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
id: linen-shape
|
id: linen-shape
|
||||||
startsIn: "[[laundry]]"
|
startsIn: "[[laundry]]"
|
||||||
initialPhase: hanging
|
initialPhase: hanging
|
||||||
|
aliases: [linen shape, shape, sheet, sheets, linen]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
linenShapeEmpty: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[servants-passage]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
hanging:
|
||||||
|
description: hanging
|
||||||
|
transitions:
|
||||||
|
- verb: wait
|
||||||
|
chipLabel: WAIT
|
||||||
|
narration: wait-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## hanging
|
## hanging
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
id: piano-echo
|
id: piano-echo
|
||||||
startsIn: "[[music-room]]"
|
startsIn: "[[music-room]]"
|
||||||
initialPhase: listening
|
initialPhase: listening
|
||||||
|
aliases: [piano echo, piano, note, key]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
musicSolved: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[hallway]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
listening:
|
||||||
|
description: listening
|
||||||
|
transitions:
|
||||||
|
- verb: play
|
||||||
|
target: piano-echo
|
||||||
|
chipLabel: PLAY NOTE
|
||||||
|
chipCommand: play note
|
||||||
|
narration: play-piano-echo-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## listening
|
## listening
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
id: portrait-woman
|
id: portrait-woman
|
||||||
startsIn: "[[burial-gallery]]"
|
startsIn: "[[burial-gallery]]"
|
||||||
initialPhase: watching
|
initialPhase: watching
|
||||||
|
aliases: [portrait woman, woman, portrait, portraits, veil, funeral veil]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
familyResemblanceSeen: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[root-chamber]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
watching:
|
||||||
|
description: watching
|
||||||
|
transitions:
|
||||||
|
- verb: examine
|
||||||
|
target: portrait-woman
|
||||||
|
chipLabel: EXAMINE PORTRAITS
|
||||||
|
chipCommand: examine portraits
|
||||||
|
narration: examine-portraits-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## watching
|
## watching
|
||||||
|
|||||||
@@ -2,6 +2,31 @@
|
|||||||
id: rainwater-basin
|
id: rainwater-basin
|
||||||
startsIn: "[[rain-room]]"
|
startsIn: "[[rain-room]]"
|
||||||
initialPhase: reflecting
|
initialPhase: reflecting
|
||||||
|
aliases: [rainwater basin, basin, water, rainwater, reflection]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
rainRoomEntered: true
|
||||||
|
houseAcceptedYou: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[wrong-hallway]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
reflecting:
|
||||||
|
description: reflecting
|
||||||
|
transitions:
|
||||||
|
- verb: look
|
||||||
|
target: rainwater-basin
|
||||||
|
chipLabel: LOOK BASIN
|
||||||
|
chipCommand: look basin
|
||||||
|
narration: look-resolved
|
||||||
|
to: resolved
|
||||||
|
- verb: examine
|
||||||
|
target: rainwater-basin
|
||||||
|
chipLabel: EXAMINE BASIN
|
||||||
|
chipCommand: examine basin
|
||||||
|
narration: look-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## reflecting
|
## reflecting
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
id: rat
|
id: rat
|
||||||
startsIn: "[[cellar-stair]]"
|
startsIn: "[[cellar-stair]]"
|
||||||
initialPhase: lurking
|
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
|
||||||
|
- verb: wait
|
||||||
|
chipLabel: WAIT
|
||||||
|
narration: wait-stays
|
||||||
|
to: lurking
|
||||||
---
|
---
|
||||||
## lurking
|
## lurking
|
||||||
A heavy rat watches you from the third step. Its eyes catch the light.
|
A heavy rat watches you from the third step. Its eyes catch the light.
|
||||||
@@ -11,3 +29,6 @@ You stamp. The rat squeals and is gone into the dark.
|
|||||||
|
|
||||||
## wait-stays
|
## wait-stays
|
||||||
The rat does not move. Neither do you.
|
The rat does not move. Neither do you.
|
||||||
|
|
||||||
|
## wrong-verb
|
||||||
|
The rat watches.
|
||||||
|
|||||||
@@ -2,6 +2,26 @@
|
|||||||
id: reflection
|
id: reflection
|
||||||
startsIn: "[[flooded-passage]]"
|
startsIn: "[[flooded-passage]]"
|
||||||
initialPhase: following
|
initialPhase: following
|
||||||
|
aliases: [reflection, water, black water, face, reflected figure]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
reflectionObscured: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[ossuary]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
following:
|
||||||
|
description: following
|
||||||
|
transitions:
|
||||||
|
- verb: use
|
||||||
|
target: reflection
|
||||||
|
chipLabel: USE SHEET
|
||||||
|
chipCommand: use water with sheet
|
||||||
|
requires:
|
||||||
|
item: "[[damp-sheet]]"
|
||||||
|
narration: obscure-water-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## following
|
## following
|
||||||
|
|||||||
@@ -2,6 +2,23 @@
|
|||||||
id: root-movement
|
id: root-movement
|
||||||
startsIn: "[[root-chamber]]"
|
startsIn: "[[root-chamber]]"
|
||||||
initialPhase: shifting
|
initialPhase: shifting
|
||||||
|
aliases: [root movement, roots, root, opening]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
rootsListenedTo: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[flooded-passage]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
shifting:
|
||||||
|
description: shifting
|
||||||
|
transitions:
|
||||||
|
- verb: listen
|
||||||
|
chipLabel: LISTEN
|
||||||
|
chipCommand: listen
|
||||||
|
narration: listen-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## shifting
|
## shifting
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
id: stair-sleeper
|
id: stair-sleeper
|
||||||
startsIn: "[[stair-up]]"
|
startsIn: "[[stair-up]]"
|
||||||
initialPhase: seated
|
initialPhase: seated
|
||||||
|
aliases: [stair sleeper, sleeper, figure, person, body]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
hallwayShifted: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[parlor]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
seated:
|
||||||
|
description: seated
|
||||||
|
transitions:
|
||||||
|
- verb: wait
|
||||||
|
chipLabel: WAIT
|
||||||
|
narration: wait-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## seated
|
## seated
|
||||||
|
|||||||
@@ -2,6 +2,38 @@
|
|||||||
id: vault-memory
|
id: vault-memory
|
||||||
startsIn: "[[vault]]"
|
startsIn: "[[vault]]"
|
||||||
initialPhase: buried
|
initialPhase: buried
|
||||||
|
aliases: [vault memory, memory, bed, photograph, photo, thing, buried thing]
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
buried:
|
||||||
|
description: buried
|
||||||
|
transitions:
|
||||||
|
- verb: read
|
||||||
|
target: family-register
|
||||||
|
chipLabel: READ REGISTER
|
||||||
|
chipCommand: read register
|
||||||
|
requires:
|
||||||
|
item: "[[family-register]]"
|
||||||
|
narration: read-register-resolved
|
||||||
|
setFlags:
|
||||||
|
nameSpoken: true
|
||||||
|
to: resolved
|
||||||
|
- verb: take
|
||||||
|
target: vault-memory
|
||||||
|
chipLabel: TAKE PHOTO
|
||||||
|
chipCommand: take photograph
|
||||||
|
narration: take-photograph-resolved
|
||||||
|
setFlags:
|
||||||
|
tookPhotograph: true
|
||||||
|
to: resolved
|
||||||
|
- verb: attack
|
||||||
|
target: vault-memory
|
||||||
|
chipLabel: ATTACK BED
|
||||||
|
chipCommand: attack bed
|
||||||
|
narration: attack-bed-resolved
|
||||||
|
setFlags:
|
||||||
|
disturbedVault: true
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## buried
|
## buried
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
id: window-guest
|
id: window-guest
|
||||||
startsIn: "[[dining-room]]"
|
startsIn: "[[dining-room]]"
|
||||||
initialPhase: standing-outside
|
initialPhase: standing-outside
|
||||||
|
aliases: [guest, window guest, curtains, curtain, window]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
curtainsClosed: true
|
||||||
|
onFailed:
|
||||||
|
narration: failed
|
||||||
|
retreatTo: "[[hallway]]"
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
standing-outside:
|
||||||
|
description: standing-outside
|
||||||
|
transitions:
|
||||||
|
- verb: close
|
||||||
|
target: window-guest
|
||||||
|
chipLabel: CLOSE CURTAINS
|
||||||
|
chipCommand: close curtains
|
||||||
|
narration: close-window-guest-resolved
|
||||||
|
to: resolved
|
||||||
---
|
---
|
||||||
|
|
||||||
## standing-outside
|
## standing-outside
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
id: halfstreet
|
||||||
|
title: Halfstreet
|
||||||
|
description: A gothic mystery.
|
||||||
|
startingRoom: "[[outside-gate]]"
|
||||||
|
startingInventory:
|
||||||
|
- "[[letter]]"
|
||||||
|
- "[[matches]]"
|
||||||
|
- "[[broken-cigarette]]"
|
||||||
|
endingPriority:
|
||||||
|
- mercy
|
||||||
|
- "true"
|
||||||
|
- replacement
|
||||||
|
- bad
|
||||||
|
- wrong
|
||||||
|
transcriptCap: 200
|
||||||
|
---
|
||||||
|
|
||||||
|
## opening-art
|
||||||
|
_ _ _ __ ____ _ _
|
||||||
|
| | | | __ _| |/ _| / ___|| |_ _ __ ___ ___| |_
|
||||||
|
| |_| |/ _` | | |_ \___ \| __| '__/ _ \/ _ \ __|
|
||||||
|
| _ | (_| | | _| ___) | |_| | | __/ __/ |_
|
||||||
|
|_| |_|\__,_|_|_| |____/ \__|_| \___|\___|\__|
|
||||||
|
|
||||||
|
## help
|
||||||
|
You arrive at the address, but you do not remember what has happened. The road behind you is gone...
|
||||||
|
|
||||||
|
This is a text adventure. Type short commands to act in the house.
|
||||||
|
|
||||||
|
Common commands:
|
||||||
|
look describe the room again
|
||||||
|
n, s, e, w, u, d move by direction
|
||||||
|
take lamp pick something up
|
||||||
|
examine letter inspect something nearby or held
|
||||||
|
read letter read a readable object
|
||||||
|
inventory see what you carry
|
||||||
|
light lamp with matches use one thing with another
|
||||||
|
wait let the room continue
|
||||||
|
undo step back once
|
||||||
|
restart begin again
|
||||||
|
theme change the terminal colors
|
||||||
|
|
||||||
|
Most commands are verb first, then the thing: examine gate, take lamp, use key on door.
|
||||||
|
|
||||||
|
## ended
|
||||||
|
The story has ended. Type `restart` or `undo`.
|
||||||
+190
-39
@@ -1,15 +1,37 @@
|
|||||||
import type { World, Room, Item } from './types'
|
import type { World, Room, Item, GameManifest, EncounterDef } from './types'
|
||||||
import {
|
import {
|
||||||
|
parseAction,
|
||||||
|
parseGame,
|
||||||
|
parseLightMechanic,
|
||||||
|
parseMessages,
|
||||||
|
parseParser,
|
||||||
|
parseUi,
|
||||||
|
parseResolveMechanic,
|
||||||
parseRoom,
|
parseRoom,
|
||||||
parseItem,
|
parseItem,
|
||||||
parseEnding,
|
parseEnding,
|
||||||
parseEncounterNarration,
|
parseEncounterNarration,
|
||||||
|
type ParsedEncounterNarration,
|
||||||
} from './loader'
|
} from './loader'
|
||||||
// Importing loader (above) triggers auto-registration of encounter narrations.
|
|
||||||
// ESM evaluates dependencies first, so by the time encounters.ts is evaluated below,
|
|
||||||
// narration() can resolve all keys.
|
|
||||||
import { encounters } from './encounters'
|
|
||||||
|
|
||||||
|
const gameFiles = import.meta.glob<string>('./game.md', {
|
||||||
|
eager: true, query: '?raw', import: 'default',
|
||||||
|
})
|
||||||
|
const parserFiles = import.meta.glob<string>('./parser.md', {
|
||||||
|
eager: true, query: '?raw', import: 'default',
|
||||||
|
})
|
||||||
|
const uiFiles = import.meta.glob<string>('./ui.md', {
|
||||||
|
eager: true, query: '?raw', import: 'default',
|
||||||
|
})
|
||||||
|
const messageFiles = import.meta.glob<string>('./messages.md', {
|
||||||
|
eager: true, query: '?raw', import: 'default',
|
||||||
|
})
|
||||||
|
const mechanicFiles = import.meta.glob<string>('./mechanics/*.md', {
|
||||||
|
eager: true, query: '?raw', import: 'default',
|
||||||
|
})
|
||||||
|
const actionFiles = import.meta.glob<string>('./actions/*.md', {
|
||||||
|
eager: true, query: '?raw', import: 'default',
|
||||||
|
})
|
||||||
const roomFiles = import.meta.glob<string>('./rooms/*.md', {
|
const roomFiles = import.meta.glob<string>('./rooms/*.md', {
|
||||||
eager: true, query: '?raw', import: 'default',
|
eager: true, query: '?raw', import: 'default',
|
||||||
})
|
})
|
||||||
@@ -23,11 +45,48 @@ const encounterFiles = import.meta.glob<string>('./encounters/*.md', {
|
|||||||
eager: true, query: '?raw', import: 'default',
|
eager: true, query: '?raw', import: 'default',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Re-parse encounter docs here so we can validate startsIn / initialPhase against encounters.ts.
|
|
||||||
// (The loader already auto-registered narrations from these same files at module init.)
|
|
||||||
const encounterDocs = Object.entries(encounterFiles).map(([path, raw]) =>
|
const encounterDocs = Object.entries(encounterFiles).map(([path, raw]) =>
|
||||||
parseEncounterNarration(raw, path),
|
parseEncounterNarration(raw, path),
|
||||||
)
|
)
|
||||||
|
const markdownEncounters: Record<string, EncounterDef> = {}
|
||||||
|
for (const doc of encounterDocs) {
|
||||||
|
if (!doc.encounter) {
|
||||||
|
throw new Error(`encounters/${doc.id}.md is missing phases frontmatter`)
|
||||||
|
}
|
||||||
|
if (markdownEncounters[doc.id]) throw new Error(`encounters/${doc.id}.md: duplicate markdown encounter id "${doc.id}"`)
|
||||||
|
markdownEncounters[doc.id] = doc.encounter
|
||||||
|
}
|
||||||
|
const encounters: Record<string, EncounterDef> = markdownEncounters
|
||||||
|
|
||||||
|
const gameEntry = Object.entries(gameFiles)[0]
|
||||||
|
if (!gameEntry) {
|
||||||
|
throw new Error('world/game.md is missing')
|
||||||
|
}
|
||||||
|
const game = parseGame(gameEntry[1], gameEntry[0])
|
||||||
|
const parserEntry = Object.entries(parserFiles)[0]
|
||||||
|
if (!parserEntry) {
|
||||||
|
throw new Error('world/parser.md is missing')
|
||||||
|
}
|
||||||
|
const parser = parseParser(parserEntry[1], parserEntry[0])
|
||||||
|
const uiEntry = Object.entries(uiFiles)[0]
|
||||||
|
const ui = uiEntry ? parseUi(uiEntry[1], uiEntry[0]) : undefined
|
||||||
|
const messageEntry = Object.entries(messageFiles)[0]
|
||||||
|
const messages = messageEntry ? parseMessages(messageEntry[1], messageEntry[0]) : undefined
|
||||||
|
const mechanics: World['mechanics'] = {}
|
||||||
|
for (const [path, raw] of Object.entries(mechanicFiles)) {
|
||||||
|
if (path.endsWith('/light.md')) {
|
||||||
|
mechanics.light = parseLightMechanic(raw, path)
|
||||||
|
} else if (path.endsWith('/resolve.md')) {
|
||||||
|
mechanics.resolve = parseResolveMechanic(raw, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: NonNullable<World['actions']> = {}
|
||||||
|
for (const [path, raw] of Object.entries(actionFiles)) {
|
||||||
|
const action = parseAction(raw, path)
|
||||||
|
if (actions[action.id]) throw new Error(`${path}: duplicate action id "${action.id}"`)
|
||||||
|
actions[action.id] = action
|
||||||
|
}
|
||||||
|
|
||||||
// Build rooms map.
|
// Build rooms map.
|
||||||
const rooms: Record<string, Room> = {}
|
const rooms: Record<string, Room> = {}
|
||||||
@@ -46,39 +105,65 @@ for (const [path, raw] of Object.entries(itemFiles)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build endings.
|
// Build endings.
|
||||||
const endings: World['endings'] = {
|
const endings: World['endings'] = {}
|
||||||
true: { whenFlags: {}, narration: '' },
|
|
||||||
wrong: { whenFlags: {}, narration: '' },
|
|
||||||
bad: { whenFlags: {}, narration: '' },
|
|
||||||
replacement: { whenFlags: {}, narration: '' },
|
|
||||||
mercy: { whenFlags: {}, narration: '' },
|
|
||||||
}
|
|
||||||
const seenEndings = new Set<string>()
|
const seenEndings = new Set<string>()
|
||||||
for (const [path, raw] of Object.entries(endingFiles)) {
|
for (const [path, raw] of Object.entries(endingFiles)) {
|
||||||
const { id, ending } = parseEnding(raw, path)
|
const { id, ending } = parseEnding(raw, path)
|
||||||
|
if (seenEndings.has(id)) throw new Error(`${path}: duplicate ending id "${id}"`)
|
||||||
endings[id] = ending
|
endings[id] = ending
|
||||||
seenEndings.add(id)
|
seenEndings.add(id)
|
||||||
}
|
}
|
||||||
const requiredEndings = ['true', 'wrong', 'bad', 'replacement', 'mercy'] as const
|
|
||||||
for (const id of requiredEndings) {
|
interface AssembleWorldInput {
|
||||||
if (!seenEndings.has(id)) {
|
game: GameManifest
|
||||||
throw new Error(`endings/${id}.md is missing — every ending id must have a markdown file.`)
|
ui?: World['ui']
|
||||||
}
|
parser?: World['parser']
|
||||||
|
messages?: World['messages']
|
||||||
|
mechanics?: World['mechanics']
|
||||||
|
actions?: World['actions']
|
||||||
|
rooms: Record<string, Room>
|
||||||
|
items: Record<string, Item>
|
||||||
|
endings: World['endings']
|
||||||
|
encounters: Record<string, EncounterDef>
|
||||||
|
encounterDocs: ParsedEncounterNarration[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-reference validation.
|
export function assembleWorld({
|
||||||
// Build set of all known flag names from encounter setFlags and ending whenFlags.
|
game,
|
||||||
const knownFlags = new Set<string>()
|
ui,
|
||||||
for (const enc of Object.values(encounters)) {
|
parser,
|
||||||
|
messages,
|
||||||
|
mechanics,
|
||||||
|
actions,
|
||||||
|
rooms,
|
||||||
|
items,
|
||||||
|
endings,
|
||||||
|
encounters,
|
||||||
|
encounterDocs,
|
||||||
|
}: AssembleWorldInput): World {
|
||||||
|
// Build set of all known flag names from encounter setFlags and ending whenFlags.
|
||||||
|
const knownFlags = new Set<string>()
|
||||||
|
for (const enc of Object.values(encounters)) {
|
||||||
if (enc.onResolved?.setFlags) {
|
if (enc.onResolved?.setFlags) {
|
||||||
for (const flagName of Object.keys(enc.onResolved.setFlags)) knownFlags.add(flagName)
|
for (const flagName of Object.keys(enc.onResolved.setFlags)) knownFlags.add(flagName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const ending of Object.values(endings)) {
|
for (const action of Object.values(actions ?? {})) {
|
||||||
|
if (action.setsFlags) {
|
||||||
|
for (const flagName of Object.keys(action.setsFlags)) knownFlags.add(flagName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const ending of Object.values(endings)) {
|
||||||
for (const flagName of Object.keys(ending.whenFlags)) knownFlags.add(flagName)
|
for (const flagName of Object.keys(ending.whenFlags)) knownFlags.add(flagName)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const room of Object.values(rooms)) {
|
for (const id of game.endingPriority) {
|
||||||
|
if (!endings[id]) {
|
||||||
|
throw new Error(`game.md: endingPriority references "${id}" but endings/${id}.md is missing.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const room of Object.values(rooms)) {
|
||||||
for (const [dir, dest] of Object.entries(room.exits)) {
|
for (const [dir, dest] of Object.entries(room.exits)) {
|
||||||
if (!rooms[dest!]) {
|
if (!rooms[dest!]) {
|
||||||
throw new Error(`rooms/${room.id}.md: exit${dir.toUpperCase()} references "${dest}" but no such room exists.`)
|
throw new Error(`rooms/${room.id}.md: exit${dir.toUpperCase()} references "${dest}" but no such room exists.`)
|
||||||
@@ -106,27 +191,93 @@ for (const room of Object.values(rooms)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate encounter narration registry: every encounter in TS has a markdown doc.
|
if (!rooms[game.startingRoom]) {
|
||||||
for (const enc of Object.values(encounters)) {
|
throw new Error(`game.md: startingRoom references "${game.startingRoom}" but no such room exists.`)
|
||||||
|
}
|
||||||
|
for (const itemId of game.startingInventory) {
|
||||||
|
if (!items[itemId]) {
|
||||||
|
throw new Error(`game.md: startingInventory references unknown item "${itemId}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionHandlers = new Map<string, string>()
|
||||||
|
for (const action of Object.values(actions ?? {})) {
|
||||||
|
if (action.handler) {
|
||||||
|
const previous = actionHandlers.get(action.handler)
|
||||||
|
if (previous) {
|
||||||
|
throw new Error(
|
||||||
|
`actions/${action.id}.md: handler "${action.handler}" is already used by actions/${previous}.md. ` +
|
||||||
|
'Only one action may own a handler-backed behavior.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
actionHandlers.set(action.handler, action.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const referencedItems: Array<[string, string]> = [
|
||||||
|
...(action.requires?.allHeld ?? []).map((id): [string, string] => ['requires.allHeld', id]),
|
||||||
|
...(action.requires?.allVisibleOrHeld ?? []).map((id): [string, string] => ['requires.allVisibleOrHeld', id]),
|
||||||
|
...(action.consumes?.inventory ?? []).map((id): [string, string] => ['consumes.inventory', id]),
|
||||||
|
...(action.decrements ? [['decrements.item', action.decrements.item] as [string, string]] : []),
|
||||||
|
]
|
||||||
|
for (const [field, itemId] of referencedItems) {
|
||||||
|
if (!items[itemId]) {
|
||||||
|
throw new Error(`actions/${action.id}.md: ${field} references unknown item "${itemId}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action.handler === 'drunk-transition') {
|
||||||
|
const config = action.drunkTransition
|
||||||
|
if (!config) {
|
||||||
|
throw new Error(`actions/${action.id}.md uses drunk-transition but is missing drunkTransition config`)
|
||||||
|
}
|
||||||
|
for (const [key, roomId] of Object.entries({
|
||||||
|
destinationRoom: config.destinationRoom,
|
||||||
|
wakeRoom: config.wakeRoom,
|
||||||
|
resetRoom: config.resetRoom,
|
||||||
|
})) {
|
||||||
|
if (!rooms[roomId]) {
|
||||||
|
throw new Error(`actions/${action.id}.md: drunkTransition.${key} references unknown room "${roomId}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate encounter narration registry: every encounter has a markdown doc.
|
||||||
|
for (const enc of Object.values(encounters)) {
|
||||||
const doc = encounterDocs.find(d => d.id === enc.id)
|
const doc = encounterDocs.find(d => d.id === enc.id)
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
throw new Error(`encounters/${enc.id}.md: missing narration markdown for encounter "${enc.id}"`)
|
throw new Error(`encounters/${enc.id}.md: missing narration markdown for encounter "${enc.id}"`)
|
||||||
}
|
}
|
||||||
if (doc.startsIn !== enc.startsIn) {
|
|
||||||
throw new Error(`encounters/${enc.id}.md: startsIn "${doc.startsIn}" does not match encounters.ts "${enc.startsIn}"`)
|
|
||||||
}
|
}
|
||||||
if (doc.initialPhase !== enc.initialPhase) {
|
|
||||||
throw new Error(`encounters/${enc.id}.md: initialPhase "${doc.initialPhase}" does not match encounters.ts "${enc.initialPhase}"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const world: World = {
|
return {
|
||||||
startingRoom: 'outside-gate',
|
game,
|
||||||
startingInventory: ['letter', 'matches', 'broken-cigarette'],
|
ui,
|
||||||
|
parser,
|
||||||
|
messages,
|
||||||
|
mechanics,
|
||||||
|
actions,
|
||||||
|
startingRoom: game.startingRoom,
|
||||||
|
startingInventory: game.startingInventory,
|
||||||
|
endingPriority: game.endingPriority,
|
||||||
rooms,
|
rooms,
|
||||||
items,
|
items,
|
||||||
encounters,
|
encounters,
|
||||||
endings,
|
endings,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const world: World = assembleWorld({
|
||||||
|
game,
|
||||||
|
ui,
|
||||||
|
parser,
|
||||||
|
messages,
|
||||||
|
mechanics,
|
||||||
|
actions,
|
||||||
|
rooms,
|
||||||
|
items,
|
||||||
|
encounters,
|
||||||
|
endings,
|
||||||
|
encounterDocs,
|
||||||
|
})
|
||||||
|
|||||||
+381
-1
@@ -1,5 +1,330 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { parseRoom, parseItem, parseEnding, parseEncounterNarration, narration, registerEncounterNarrations, _resetEncounterNarrationRegistry } from './loader'
|
import {
|
||||||
|
parseGame,
|
||||||
|
parseAction,
|
||||||
|
parseLightMechanic,
|
||||||
|
parseResolveMechanic,
|
||||||
|
parseMessages,
|
||||||
|
parseParser,
|
||||||
|
parseUi,
|
||||||
|
parseRoom,
|
||||||
|
parseItem,
|
||||||
|
parseEnding,
|
||||||
|
parseEncounterNarration,
|
||||||
|
narration,
|
||||||
|
registerEncounterNarrations,
|
||||||
|
_resetEncounterNarrationRegistry,
|
||||||
|
} from './loader'
|
||||||
|
|
||||||
|
const GAME_MD = `---
|
||||||
|
id: halfstreet
|
||||||
|
title: Halfstreet
|
||||||
|
description: A gothic mystery.
|
||||||
|
startingRoom: "[[outside-gate]]"
|
||||||
|
startingInventory:
|
||||||
|
- "[[letter]]"
|
||||||
|
endingPriority:
|
||||||
|
- "true"
|
||||||
|
- wrong
|
||||||
|
transcriptCap: 200
|
||||||
|
---
|
||||||
|
|
||||||
|
## opening-art
|
||||||
|
HALFSTREET
|
||||||
|
|
||||||
|
## help
|
||||||
|
Help text.
|
||||||
|
|
||||||
|
## ended
|
||||||
|
The story has ended.
|
||||||
|
`
|
||||||
|
|
||||||
|
describe('parseGame', () => {
|
||||||
|
it('parses manifest frontmatter and sections', () => {
|
||||||
|
const game = parseGame(GAME_MD, 'game.md')
|
||||||
|
expect(game).toEqual({
|
||||||
|
id: 'halfstreet',
|
||||||
|
title: 'Halfstreet',
|
||||||
|
description: 'A gothic mystery.',
|
||||||
|
startingRoom: 'outside-gate',
|
||||||
|
startingInventory: ['letter'],
|
||||||
|
endingPriority: ['true', 'wrong'],
|
||||||
|
transcriptCap: 200,
|
||||||
|
openingArt: 'HALFSTREET',
|
||||||
|
helpText: 'Help text.',
|
||||||
|
endedText: 'The story has ended.',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when a required section is missing', () => {
|
||||||
|
const incomplete = GAME_MD.replace('## help\nHelp text.\n\n', '')
|
||||||
|
expect(() => parseGame(incomplete, 'game.md')).toThrow(/missing required section.*help/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const PARSER_MD = `---
|
||||||
|
directions:
|
||||||
|
n: [n, north]
|
||||||
|
s: [s, south]
|
||||||
|
e: [e, east]
|
||||||
|
w: [w, west]
|
||||||
|
u: [u, up]
|
||||||
|
d: [d, down]
|
||||||
|
prepositions: [with, on]
|
||||||
|
stopWords: [the]
|
||||||
|
noTargetVerbs: [look]
|
||||||
|
metaVerbs: [restart]
|
||||||
|
verbs:
|
||||||
|
go: [go]
|
||||||
|
look: [look, observe]
|
||||||
|
take: [take, pick up]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Parser Vocabulary
|
||||||
|
`
|
||||||
|
|
||||||
|
describe('parseParser', () => {
|
||||||
|
it('parses parser vocabulary frontmatter', () => {
|
||||||
|
const parser = parseParser(PARSER_MD, 'parser.md')
|
||||||
|
expect(parser.verbs.look).toEqual(['look', 'observe'])
|
||||||
|
expect(parser.verbs.take).toContain('pick up')
|
||||||
|
expect(parser.directions.n).toEqual(['n', 'north'])
|
||||||
|
expect(parser.noTargetVerbs).toEqual(['look'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unsupported verb keys', () => {
|
||||||
|
const invalid = PARSER_MD.replace(' take: [take, pick up]', ' dance: [dance]')
|
||||||
|
expect(() => parseParser(invalid, 'parser.md')).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const UI_MD = `---
|
||||||
|
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
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI
|
||||||
|
`
|
||||||
|
|
||||||
|
describe('parseUi', () => {
|
||||||
|
it('parses site metadata, footer config, and feature toggles', () => {
|
||||||
|
const ui = parseUi(UI_MD, 'ui.md')
|
||||||
|
expect(ui.pageTitle).toBe('Halfstreet - Ethan J Lewis')
|
||||||
|
expect(ui.footer.buildLabel).toBe('Build #')
|
||||||
|
expect(ui.footer.links.map((link) => link.label)).toEqual(['GNU 3.0', 'Source Code'])
|
||||||
|
expect(ui.features).toEqual({
|
||||||
|
chips: true,
|
||||||
|
lightMeter: true,
|
||||||
|
typedEffect: true,
|
||||||
|
roomScroll: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const LIGHT_MECHANIC_MD = `---
|
||||||
|
enabled: true
|
||||||
|
handler: light
|
||||||
|
maxTurns: 3
|
||||||
|
burnOn: [wait]
|
||||||
|
stateKeys:
|
||||||
|
lit: isLit
|
||||||
|
burn: fuel
|
||||||
|
ui:
|
||||||
|
meter: true
|
||||||
|
icon: candle
|
||||||
|
---
|
||||||
|
|
||||||
|
## noLighter
|
||||||
|
No flame.
|
||||||
|
|
||||||
|
## flameDies
|
||||||
|
Dark again.
|
||||||
|
`
|
||||||
|
|
||||||
|
describe('parseLightMechanic', () => {
|
||||||
|
it('parses configurable light mechanic frontmatter and messages', () => {
|
||||||
|
const light = parseLightMechanic(LIGHT_MECHANIC_MD, 'mechanics/light.md')
|
||||||
|
expect(light.enabled).toBe(true)
|
||||||
|
expect(light.maxTurns).toBe(3)
|
||||||
|
expect(light.burnOn).toEqual(['wait'])
|
||||||
|
expect(light.stateKeys).toEqual({ lit: 'isLit', burn: 'fuel' })
|
||||||
|
expect(light.messages?.noLighter).toBe('No flame.')
|
||||||
|
expect(light.messages?.flameDies).toBe('Dark again.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown light message sections', () => {
|
||||||
|
const invalid = `${LIGHT_MECHANIC_MD}
|
||||||
|
## typo
|
||||||
|
No.
|
||||||
|
`
|
||||||
|
expect(() => parseLightMechanic(invalid, 'mechanics/light.md')).toThrow(/unknown light mechanic section "## typo"/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const RESOLVE_MECHANIC_MD = `---
|
||||||
|
enabled: true
|
||||||
|
handler: resolve
|
||||||
|
ladder: [steady, shaken, reeling, returning]
|
||||||
|
wrongVerbCost: 2
|
||||||
|
safeRooms:
|
||||||
|
recoverySteps: 2
|
||||||
|
failure:
|
||||||
|
retreatAt: returning
|
||||||
|
afterRetreat: reeling
|
||||||
|
---
|
||||||
|
|
||||||
|
# Resolve
|
||||||
|
`
|
||||||
|
|
||||||
|
describe('parseResolveMechanic', () => {
|
||||||
|
it('parses configurable resolve mechanic frontmatter', () => {
|
||||||
|
const resolve = parseResolveMechanic(RESOLVE_MECHANIC_MD, 'mechanics/resolve.md')
|
||||||
|
expect(resolve.enabled).toBe(true)
|
||||||
|
expect(resolve.ladder).toEqual(['steady', 'shaken', 'reeling', 'returning'])
|
||||||
|
expect(resolve.wrongVerbCost).toBe(2)
|
||||||
|
expect(resolve.safeRooms.recoverySteps).toBe(2)
|
||||||
|
expect(resolve.failure.afterRetreat).toBe('reeling')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects failure levels outside the ladder', () => {
|
||||||
|
const invalid = RESOLVE_MECHANIC_MD.replace('ladder: [steady, shaken, reeling, returning]', 'ladder: [steady, shaken]')
|
||||||
|
expect(() => parseResolveMechanic(invalid, 'mechanics/resolve.md')).toThrow(/failure\.retreatAt must be present in ladder/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const ACTION_MD = `---
|
||||||
|
id: burn-letter
|
||||||
|
verbs: [use]
|
||||||
|
requires:
|
||||||
|
allVisibleOrHeld:
|
||||||
|
- "[[letter]]"
|
||||||
|
- "[[matches]]"
|
||||||
|
consumes:
|
||||||
|
inventory:
|
||||||
|
- "[[letter]]"
|
||||||
|
decrements:
|
||||||
|
item: "[[matches]]"
|
||||||
|
stateKey: uses
|
||||||
|
setsFlags:
|
||||||
|
letterBurned: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## success
|
||||||
|
The letter catches at one corner. In a few breaths it is ash.
|
||||||
|
|
||||||
|
## spent
|
||||||
|
The matchbook is empty.
|
||||||
|
`
|
||||||
|
|
||||||
|
const HANDLER_ACTION_MD = `---
|
||||||
|
id: drink-whiskey
|
||||||
|
verbs: [drink]
|
||||||
|
handler: drunk-transition
|
||||||
|
requires:
|
||||||
|
allHeld:
|
||||||
|
- "[[whiskey]]"
|
||||||
|
consumes:
|
||||||
|
inventory:
|
||||||
|
- "[[whiskey]]"
|
||||||
|
drunkTransition:
|
||||||
|
destinationRoom: "[[drunk-hall]]"
|
||||||
|
maxMoves: 20
|
||||||
|
wakeRoom: "[[foyer]]"
|
||||||
|
resetRoom: "[[kitchen]]"
|
||||||
|
---
|
||||||
|
|
||||||
|
## success
|
||||||
|
You drink from the bottle.
|
||||||
|
|
||||||
|
## secretFoundPassOut
|
||||||
|
The faceless man steps backward.
|
||||||
|
|
||||||
|
## tooManyMovesPassOut
|
||||||
|
The rooms keep turning.
|
||||||
|
|
||||||
|
## reset
|
||||||
|
The bottle is not with you.
|
||||||
|
`
|
||||||
|
|
||||||
|
describe('parseAction', () => {
|
||||||
|
it('parses a declarative action with wikilinks and sections', () => {
|
||||||
|
const action = parseAction(ACTION_MD, 'actions/burn-letter.md')
|
||||||
|
expect(action.id).toBe('burn-letter')
|
||||||
|
expect(action.verbs).toEqual(['use'])
|
||||||
|
expect(action.requires?.allVisibleOrHeld).toEqual(['letter', 'matches'])
|
||||||
|
expect(action.consumes?.inventory).toEqual(['letter'])
|
||||||
|
expect(action.decrements).toEqual({ item: 'matches', stateKey: 'uses' })
|
||||||
|
expect(action.setsFlags).toEqual({ letterBurned: true })
|
||||||
|
expect(action.messages.success).toContain('ash')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses a handler-backed drunk transition action', () => {
|
||||||
|
const action = parseAction(HANDLER_ACTION_MD, 'actions/drink-whiskey.md')
|
||||||
|
expect(action.id).toBe('drink-whiskey')
|
||||||
|
expect(action.handler).toBe('drunk-transition')
|
||||||
|
expect(action.requires?.allHeld).toEqual(['whiskey'])
|
||||||
|
expect(action.consumes?.inventory).toEqual(['whiskey'])
|
||||||
|
expect(action.drunkTransition).toEqual({
|
||||||
|
destinationRoom: 'drunk-hall',
|
||||||
|
maxMoves: 20,
|
||||||
|
wakeRoom: 'foyer',
|
||||||
|
resetRoom: 'kitchen',
|
||||||
|
})
|
||||||
|
expect(action.messages.tooManyMovesPassOut).toContain('turning')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires a success section', () => {
|
||||||
|
const invalid = ACTION_MD.replace(/## success[\s\S]*?## spent/, '## spent')
|
||||||
|
expect(() => parseAction(invalid, 'actions/burn-letter.md')).toThrow(/missing required section "## success"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires handler-specific sections for drunk transition actions', () => {
|
||||||
|
const invalid = HANDLER_ACTION_MD.replace(/## tooManyMovesPassOut[\s\S]*?## reset/, '## reset')
|
||||||
|
expect(() => parseAction(invalid, 'actions/drink-whiskey.md')).toThrow(
|
||||||
|
/missing required section "## tooManyMovesPassOut" for handler "drunk-transition"/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const MESSAGES_MD = `## unknown-verb
|
||||||
|
No.
|
||||||
|
|
||||||
|
## inventory-empty
|
||||||
|
Nothing held.
|
||||||
|
`
|
||||||
|
|
||||||
|
describe('parseMessages', () => {
|
||||||
|
it('parses keyed message sections', () => {
|
||||||
|
expect(parseMessages(MESSAGES_MD, 'messages.md')).toEqual({
|
||||||
|
'unknown-verb': 'No.',
|
||||||
|
'inventory-empty': 'Nothing held.',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unknown message keys', () => {
|
||||||
|
const invalid = `## typo-key
|
||||||
|
No.
|
||||||
|
`
|
||||||
|
expect(() => parseMessages(invalid, 'messages.md')).toThrow(/unknown message section "## typo-key"/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const FOYER_MD = `---
|
const FOYER_MD = `---
|
||||||
id: foyer
|
id: foyer
|
||||||
@@ -251,6 +576,24 @@ const RAT_MD = `---
|
|||||||
id: rat
|
id: rat
|
||||||
startsIn: "[[cellar-stair]]"
|
startsIn: "[[cellar-stair]]"
|
||||||
initialPhase: lurking
|
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-resolved
|
||||||
|
to: resolved
|
||||||
|
- verb: wait
|
||||||
|
chipLabel: WAIT
|
||||||
|
narration: wait-stays
|
||||||
|
to: lurking
|
||||||
---
|
---
|
||||||
|
|
||||||
## lurking
|
## lurking
|
||||||
@@ -261,6 +604,9 @@ You stamp. The rat squeals and is gone into the dark.
|
|||||||
|
|
||||||
## wait-stays
|
## wait-stays
|
||||||
The rat does not move. Neither do you.
|
The rat does not move. Neither do you.
|
||||||
|
|
||||||
|
## wrong-verb
|
||||||
|
The rat watches.
|
||||||
`
|
`
|
||||||
|
|
||||||
describe('parseEncounterNarration', () => {
|
describe('parseEncounterNarration', () => {
|
||||||
@@ -273,7 +619,41 @@ describe('parseEncounterNarration', () => {
|
|||||||
lurking: 'A heavy rat watches you from the third step. Its eyes catch the light.',
|
lurking: 'A heavy rat watches you from the third step. Its eyes catch the light.',
|
||||||
'attack-resolved': 'You stamp. The rat squeals and is gone into the dark.',
|
'attack-resolved': 'You stamp. The rat squeals and is gone into the dark.',
|
||||||
'wait-stays': 'The rat does not move. Neither do you.',
|
'wait-stays': 'The rat does not move. Neither do you.',
|
||||||
|
'wrong-verb': 'The rat watches.',
|
||||||
})
|
})
|
||||||
|
expect(doc.encounter).toMatchObject({
|
||||||
|
id: 'rat',
|
||||||
|
startsIn: 'cellar-stair',
|
||||||
|
initialPhase: 'lurking',
|
||||||
|
onResolved: { setFlags: { ratGone: true } },
|
||||||
|
defaultWrongVerbNarration: 'The rat watches.',
|
||||||
|
})
|
||||||
|
const lurking = doc.encounter?.phases.lurking
|
||||||
|
expect(lurking).toBeDefined()
|
||||||
|
expect(lurking?.description).toContain('heavy rat')
|
||||||
|
expect(lurking?.transitions).toEqual([
|
||||||
|
{
|
||||||
|
verb: 'attack',
|
||||||
|
target: 'rat',
|
||||||
|
chipLabel: 'ATTACK RAT',
|
||||||
|
chipCommand: 'attack rat',
|
||||||
|
narration: 'You stamp. The rat squeals and is gone into the dark.',
|
||||||
|
to: 'resolved',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
verb: 'wait',
|
||||||
|
chipLabel: 'WAIT',
|
||||||
|
narration: 'The rat does not move. Neither do you.',
|
||||||
|
to: 'lurking',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when encounter frontmatter points at a missing prose section', () => {
|
||||||
|
const invalid = RAT_MD.replace('narration: attack-resolved', 'narration: missing-key')
|
||||||
|
expect(() => parseEncounterNarration(invalid, 'encounters/rat.md')).toThrow(
|
||||||
|
/frontmatter references missing section "## missing-key"/,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws when no sections are present', () => {
|
it('throws when no sections are present', () => {
|
||||||
|
|||||||
+203
-6
@@ -18,9 +18,20 @@ function matter(raw: string): ParsedFile {
|
|||||||
const data = (parsed && typeof parsed === 'object' ? parsed : {}) as Record<string, unknown>
|
const data = (parsed && typeof parsed === 'object' ? parsed : {}) as Record<string, unknown>
|
||||||
return { data, content }
|
return { data, content }
|
||||||
}
|
}
|
||||||
import type { Room, RoomDescriptions, Item } from './types'
|
import { DEFAULT_WORLD_MESSAGES, type DeclarativeAction, type EncounterDef, type Room, type RoomDescriptions, type Item, type WorldMessages } from './types'
|
||||||
import type { Direction } from '../engine/types'
|
import type { Direction } from '../engine/types'
|
||||||
import { roomFrontmatterSchema, itemFrontmatterSchema, endingFrontmatterSchema, encounterFrontmatterSchema } from './schema'
|
import {
|
||||||
|
gameFrontmatterSchema,
|
||||||
|
actionFrontmatterSchema,
|
||||||
|
lightMechanicFrontmatterSchema,
|
||||||
|
parserFrontmatterSchema,
|
||||||
|
resolveMechanicFrontmatterSchema,
|
||||||
|
roomFrontmatterSchema,
|
||||||
|
itemFrontmatterSchema,
|
||||||
|
endingFrontmatterSchema,
|
||||||
|
encounterFrontmatterSchema,
|
||||||
|
uiFrontmatterSchema,
|
||||||
|
} from './schema'
|
||||||
|
|
||||||
const WIKILINK = /^\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$/
|
const WIKILINK = /^\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$/
|
||||||
|
|
||||||
@@ -78,6 +89,139 @@ const DIR_KEYS: Record<Direction, { exit: string; requires: string; locked: stri
|
|||||||
|
|
||||||
const REQUIRED_ROOM_SECTIONS = ['first-visit', 'revisit', 'examined'] as const
|
const REQUIRED_ROOM_SECTIONS = ['first-visit', 'revisit', 'examined'] as const
|
||||||
|
|
||||||
|
const REQUIRED_GAME_SECTIONS = ['opening-art', 'help', 'ended'] as const
|
||||||
|
|
||||||
|
export function parseGame(raw: string, sourcePath: string) {
|
||||||
|
const parsed = matter(raw)
|
||||||
|
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
|
||||||
|
const fm = gameFrontmatterSchema.parse(frontmatter)
|
||||||
|
const sections = splitSections(parsed.content)
|
||||||
|
for (const key of REQUIRED_GAME_SECTIONS) {
|
||||||
|
if (!(key in sections)) {
|
||||||
|
throw new Error(`${sourcePath}: missing required section "## ${key}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: fm.id,
|
||||||
|
title: fm.title,
|
||||||
|
description: fm.description,
|
||||||
|
startingRoom: fm.startingRoom,
|
||||||
|
startingInventory: fm.startingInventory,
|
||||||
|
endingPriority: fm.endingPriority,
|
||||||
|
transcriptCap: fm.transcriptCap,
|
||||||
|
openingArt: sections['opening-art']!,
|
||||||
|
helpText: sections['help']!,
|
||||||
|
endedText: sections['ended']!,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseParser(raw: string, _sourcePath: string) {
|
||||||
|
const parsed = matter(raw)
|
||||||
|
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
|
||||||
|
return parserFrontmatterSchema.parse(frontmatter)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUi(raw: string, _sourcePath: string) {
|
||||||
|
const parsed = matter(raw)
|
||||||
|
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
|
||||||
|
return uiFrontmatterSchema.parse(frontmatter)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMessages(raw: string, sourcePath: string): WorldMessages {
|
||||||
|
const parsed = matter(raw)
|
||||||
|
const sections = splitSections(parsed.content)
|
||||||
|
const allowed = Object.keys(DEFAULT_WORLD_MESSAGES)
|
||||||
|
for (const key of Object.keys(sections)) {
|
||||||
|
if (!allowed.includes(key)) {
|
||||||
|
throw new Error(`${sourcePath}: unknown message section "## ${key}". Allowed: ${allowed.join(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sections as WorldMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIGHT_MESSAGE_KEYS = [
|
||||||
|
'useLighterWithWhat',
|
||||||
|
'cannotLight',
|
||||||
|
'alreadyLit',
|
||||||
|
'notHelpful',
|
||||||
|
'spent',
|
||||||
|
'noLighter',
|
||||||
|
'cannotExtinguish',
|
||||||
|
'notLit',
|
||||||
|
'dropLit',
|
||||||
|
'flameCatches',
|
||||||
|
'flameDies',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function parseLightMechanic(raw: string, sourcePath: string) {
|
||||||
|
const parsed = matter(raw)
|
||||||
|
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
|
||||||
|
const fm = lightMechanicFrontmatterSchema.parse(frontmatter)
|
||||||
|
const sections = splitSections(parsed.content)
|
||||||
|
for (const key of Object.keys(sections)) {
|
||||||
|
if (!LIGHT_MESSAGE_KEYS.includes(key as typeof LIGHT_MESSAGE_KEYS[number])) {
|
||||||
|
throw new Error(`${sourcePath}: unknown light mechanic section "## ${key}". Allowed: ${LIGHT_MESSAGE_KEYS.join(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fm,
|
||||||
|
messages: sections,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseResolveMechanic(raw: string, _sourcePath: string) {
|
||||||
|
const parsed = matter(raw)
|
||||||
|
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
|
||||||
|
return resolveMechanicFrontmatterSchema.parse(frontmatter)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_SECTION_KEYS = ['success', 'spent', 'missingRequired', 'secretFoundPassOut', 'tooManyMovesPassOut', 'reset'] as const
|
||||||
|
type ActionSectionKey = typeof ACTION_SECTION_KEYS[number]
|
||||||
|
const ACTION_REQUIRED_SECTIONS: Record<NonNullable<DeclarativeAction['handler']> | 'default', ActionSectionKey[]> = {
|
||||||
|
default: ['success'],
|
||||||
|
'drunk-transition': ['success', 'secretFoundPassOut', 'tooManyMovesPassOut', 'reset'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAction(raw: string, sourcePath: string): DeclarativeAction {
|
||||||
|
const parsed = matter(raw)
|
||||||
|
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
|
||||||
|
const fm = actionFrontmatterSchema.parse(frontmatter)
|
||||||
|
const sections = splitSections(parsed.content)
|
||||||
|
for (const key of Object.keys(sections)) {
|
||||||
|
if (!ACTION_SECTION_KEYS.includes(key as ActionSectionKey)) {
|
||||||
|
throw new Error(`${sourcePath}: unknown action section "## ${key}". Allowed: ${ACTION_SECTION_KEYS.join(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const requiredSections = ACTION_REQUIRED_SECTIONS[fm.handler ?? 'default']
|
||||||
|
for (const key of requiredSections) {
|
||||||
|
if (!sections[key]) {
|
||||||
|
const scope = fm.handler ? ` for handler "${fm.handler}"` : ''
|
||||||
|
throw new Error(`${sourcePath}: missing required section "## ${key}"${scope}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: fm.id,
|
||||||
|
verbs: fm.verbs,
|
||||||
|
handler: fm.handler,
|
||||||
|
requires: fm.requires,
|
||||||
|
consumes: fm.consumes,
|
||||||
|
decrements: fm.decrements,
|
||||||
|
setsFlags: fm.setsFlags,
|
||||||
|
drunkTransition: fm.drunkTransition,
|
||||||
|
messages: {
|
||||||
|
success: sections.success!,
|
||||||
|
spent: sections.spent,
|
||||||
|
missingRequired: sections.missingRequired,
|
||||||
|
secretFoundPassOut: sections.secretFoundPassOut,
|
||||||
|
tooManyMovesPassOut: sections.tooManyMovesPassOut,
|
||||||
|
reset: sections.reset,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function parseRoom(raw: string, sourcePath: string): Room {
|
export function parseRoom(raw: string, sourcePath: string): Room {
|
||||||
const parsed = matter(raw)
|
const parsed = matter(raw)
|
||||||
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
|
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
|
||||||
@@ -179,7 +323,7 @@ export function parseItem(raw: string, sourcePath: string): Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedEnding {
|
export interface ParsedEnding {
|
||||||
id: 'true' | 'wrong' | 'bad' | 'replacement' | 'mercy'
|
id: string
|
||||||
ending: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
|
ending: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +343,7 @@ export interface ParsedEncounterNarration {
|
|||||||
startsIn: string
|
startsIn: string
|
||||||
initialPhase: string
|
initialPhase: string
|
||||||
narrations: Record<string, string>
|
narrations: Record<string, string>
|
||||||
|
encounter?: EncounterDef
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseEncounterNarration(raw: string, sourcePath: string): ParsedEncounterNarration {
|
export function parseEncounterNarration(raw: string, sourcePath: string): ParsedEncounterNarration {
|
||||||
@@ -209,14 +354,67 @@ export function parseEncounterNarration(raw: string, sourcePath: string): Parsed
|
|||||||
if (Object.keys(narrations).length === 0) {
|
if (Object.keys(narrations).length === 0) {
|
||||||
throw new Error(`${sourcePath}: no narration sections found`)
|
throw new Error(`${sourcePath}: no narration sections found`)
|
||||||
}
|
}
|
||||||
|
const encounter = fm.phases
|
||||||
|
? buildEncounterFromMarkdown(fm, narrations, sourcePath)
|
||||||
|
: undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: fm.id,
|
id: fm.id,
|
||||||
startsIn: fm.startsIn,
|
startsIn: fm.startsIn,
|
||||||
initialPhase: fm.initialPhase,
|
initialPhase: fm.initialPhase,
|
||||||
narrations,
|
narrations,
|
||||||
|
encounter,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function proseSection(sections: Record<string, string>, key: string, sourcePath: string): string {
|
||||||
|
const text = sections[key]
|
||||||
|
if (text === undefined) {
|
||||||
|
const available = Object.keys(sections).join(', ') || '(none)'
|
||||||
|
throw new Error(`${sourcePath}: frontmatter references missing section "## ${key}". Available: ${available}`)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEncounterFromMarkdown(
|
||||||
|
fm: ReturnType<typeof encounterFrontmatterSchema.parse>,
|
||||||
|
sections: Record<string, string>,
|
||||||
|
sourcePath: string,
|
||||||
|
): EncounterDef {
|
||||||
|
const phases: EncounterDef['phases'] = {}
|
||||||
|
for (const [phaseId, phase] of Object.entries(fm.phases ?? {})) {
|
||||||
|
phases[phaseId] = {
|
||||||
|
description: proseSection(sections, phase.description, sourcePath),
|
||||||
|
transitions: phase.transitions.map((transition) => ({
|
||||||
|
...transition,
|
||||||
|
narration: proseSection(sections, transition.narration, sourcePath),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!phases[fm.initialPhase]) {
|
||||||
|
throw new Error(`${sourcePath}: initialPhase "${fm.initialPhase}" is not defined in phases`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const encounter: EncounterDef = {
|
||||||
|
id: fm.id,
|
||||||
|
startsIn: fm.startsIn,
|
||||||
|
initialPhase: fm.initialPhase,
|
||||||
|
phases,
|
||||||
|
}
|
||||||
|
if (fm.aliases) encounter.aliases = fm.aliases
|
||||||
|
if (fm.onResolved) encounter.onResolved = fm.onResolved
|
||||||
|
if (fm.onFailed) {
|
||||||
|
encounter.onFailed = {
|
||||||
|
narration: proseSection(sections, fm.onFailed.narration, sourcePath),
|
||||||
|
retreatTo: fm.onFailed.retreatTo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fm.defaultWrongVerbNarration) {
|
||||||
|
encounter.defaultWrongVerbNarration = proseSection(sections, fm.defaultWrongVerbNarration, sourcePath)
|
||||||
|
}
|
||||||
|
return encounter
|
||||||
|
}
|
||||||
|
|
||||||
const encounterNarrationRegistry = new Map<string, Map<string, string>>()
|
const encounterNarrationRegistry = new Map<string, Map<string, string>>()
|
||||||
|
|
||||||
export function registerEncounterNarrations(docs: ParsedEncounterNarration[]): void {
|
export function registerEncounterNarrations(docs: ParsedEncounterNarration[]): void {
|
||||||
@@ -246,9 +444,8 @@ export function narration(encounterId: string, key: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-register encounter narrations from co-located markdown files at module init.
|
// Auto-register encounter narrations from co-located markdown files at module init.
|
||||||
// This populates the registry BEFORE encounters.ts is evaluated (ESM evaluates dependencies first),
|
// This keeps the narration() helper available for tests and any remaining
|
||||||
// so encounters.ts can call narration() at top level without explicit ordering.
|
// TypeScript escape hatches that need to reference encounter prose by key.
|
||||||
// While src/mystery/world/encounters/ does not yet exist (Task 8 creates it), this is a no-op.
|
|
||||||
const _encounterFiles = import.meta.glob<string>('./encounters/*.md', {
|
const _encounterFiles = import.meta.glob<string>('./encounters/*.md', {
|
||||||
eager: true, query: '?raw', import: 'default',
|
eager: true, query: '?raw', import: 'default',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
enabled: true
|
||||||
|
handler: light
|
||||||
|
maxTurns: 6
|
||||||
|
burnOn:
|
||||||
|
- move
|
||||||
|
- wait
|
||||||
|
stateKeys:
|
||||||
|
lit: lit
|
||||||
|
burn: burn
|
||||||
|
ui:
|
||||||
|
meter: true
|
||||||
|
icon: candle
|
||||||
|
---
|
||||||
|
|
||||||
|
# Light
|
||||||
|
|
||||||
|
The light mechanic controls lightable inventory items, the burn timer, and the terminal meter.
|
||||||
|
|
||||||
|
## useLighterWithWhat
|
||||||
|
Use match with what?
|
||||||
|
|
||||||
|
## cannotLight
|
||||||
|
You can't light that.
|
||||||
|
|
||||||
|
## alreadyLit
|
||||||
|
It's already lit.
|
||||||
|
|
||||||
|
## notHelpful
|
||||||
|
That isn't going to help.
|
||||||
|
|
||||||
|
## spent
|
||||||
|
It is spent.
|
||||||
|
|
||||||
|
## noLighter
|
||||||
|
You have nothing to light it with.
|
||||||
|
|
||||||
|
## cannotExtinguish
|
||||||
|
You can't extinguish that.
|
||||||
|
|
||||||
|
## notLit
|
||||||
|
It isn't lit.
|
||||||
|
|
||||||
|
## dropLit
|
||||||
|
Extinguish it first.
|
||||||
|
|
||||||
|
## flameCatches
|
||||||
|
It catches.
|
||||||
|
|
||||||
|
## flameDies
|
||||||
|
The flame dies.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
enabled: true
|
||||||
|
handler: resolve
|
||||||
|
ladder:
|
||||||
|
- steady
|
||||||
|
- shaken
|
||||||
|
- reeling
|
||||||
|
- returning
|
||||||
|
wrongVerbCost: 1
|
||||||
|
safeRooms:
|
||||||
|
recoverySteps: 1
|
||||||
|
failure:
|
||||||
|
retreatAt: returning
|
||||||
|
afterRetreat: shaken
|
||||||
|
---
|
||||||
|
|
||||||
|
# Resolve
|
||||||
|
|
||||||
|
The resolve mechanic controls how encounters wear down the player, how safe rooms restore resolve, and which resolve level the player has after an encounter forces a retreat.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
## unknown-verb
|
||||||
|
You consider the words, but they don't fit this place.
|
||||||
|
|
||||||
|
## unknown-noun
|
||||||
|
You don't see anything like that here.
|
||||||
|
|
||||||
|
## malformed
|
||||||
|
You hesitate.
|
||||||
|
|
||||||
|
## nothing-to-confirm
|
||||||
|
Nothing to confirm.
|
||||||
|
|
||||||
|
## cancelled
|
||||||
|
Cancelled.
|
||||||
|
|
||||||
|
## nothing-to-choose
|
||||||
|
Nothing to choose between.
|
||||||
|
|
||||||
|
## no-undo
|
||||||
|
There is no further back.
|
||||||
|
|
||||||
|
## use-lighter-with-what
|
||||||
|
Use match with what?
|
||||||
|
|
||||||
|
## use-unknown
|
||||||
|
You can't think how to use that here.
|
||||||
|
|
||||||
|
## nothing-happens
|
||||||
|
Nothing happens.
|
||||||
|
|
||||||
|
## nowhere
|
||||||
|
You are nowhere.
|
||||||
|
|
||||||
|
## no-exit
|
||||||
|
You can't go that way.
|
||||||
|
|
||||||
|
## unfinished-exit
|
||||||
|
The way ahead is unfinished.
|
||||||
|
|
||||||
|
## cannot-drink
|
||||||
|
You can't drink that.
|
||||||
|
|
||||||
|
## need-carrying
|
||||||
|
You'd have to be carrying it.
|
||||||
|
|
||||||
|
## time-passes
|
||||||
|
Time passes.
|
||||||
|
|
||||||
|
## listen
|
||||||
|
You listen. The house listens back.
|
||||||
|
|
||||||
|
## see-nothing
|
||||||
|
You see nothing.
|
||||||
|
|
||||||
|
## inventory-empty
|
||||||
|
You are empty-handed.
|
||||||
|
|
||||||
|
## inventory-heading
|
||||||
|
You are carrying:
|
||||||
|
|
||||||
|
## dont-see-here
|
||||||
|
You don't see that here.
|
||||||
|
|
||||||
|
## cannot-take
|
||||||
|
You can't take that.
|
||||||
|
|
||||||
|
## already-have
|
||||||
|
You already have it.
|
||||||
|
|
||||||
|
## taken
|
||||||
|
Taken.
|
||||||
|
|
||||||
|
## dont-have
|
||||||
|
You don't have that.
|
||||||
|
|
||||||
|
## drop-lit
|
||||||
|
Extinguish it first.
|
||||||
|
|
||||||
|
## dropped
|
||||||
|
Dropped.
|
||||||
|
|
||||||
|
## dont-see-anything
|
||||||
|
You don't see anything like that.
|
||||||
|
|
||||||
|
## nothing-to-read
|
||||||
|
There's nothing to read on it.
|
||||||
|
|
||||||
|
## cannot-light
|
||||||
|
You can't light that.
|
||||||
|
|
||||||
|
## already-lit
|
||||||
|
It's already lit.
|
||||||
|
|
||||||
|
## not-helpful
|
||||||
|
That isn't going to help.
|
||||||
|
|
||||||
|
## spent
|
||||||
|
It is spent.
|
||||||
|
|
||||||
|
## no-lighter
|
||||||
|
You have nothing to light it with.
|
||||||
|
|
||||||
|
## cannot-extinguish
|
||||||
|
You can't extinguish that.
|
||||||
|
|
||||||
|
## not-lit
|
||||||
|
It isn't lit.
|
||||||
|
|
||||||
|
## flame-catches
|
||||||
|
It catches.
|
||||||
|
|
||||||
|
## flame-dies
|
||||||
|
The flame dies.
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
# Open-Source Authoring Roadmap
|
||||||
|
|
||||||
|
Goal: make Halfstreet usable as a small text-adventure toolkit, where a new author can create or fork a story mostly by editing markdown inside this `src/world` Obsidian vault.
|
||||||
|
|
||||||
|
Bias: if a setting, rule, phrase, object, or workflow can reasonably live in markdown frontmatter or markdown sections, put it there. TypeScript should remain the runtime, validator, and escape hatch for genuinely executable behavior.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
- Keep `src/world` as the authoring surface. Authors should not need to hunt through `src/engine`, `src/ui`, or `src/pages` for ordinary story changes.
|
||||||
|
- Prefer markdown files with frontmatter over TypeScript config when the data is declarative.
|
||||||
|
- Preserve Obsidian-friendly wikilinks for references between rooms, items, encounters, endings, mechanics, and author notes.
|
||||||
|
- Separate generic engine behavior from Halfstreet-specific story behavior.
|
||||||
|
- Keep validation strict. Markdown should be friendly to write, but mistakes should fail loudly during dev and CI.
|
||||||
|
- Make every new abstraction prove itself against the current Halfstreet content before calling it reusable.
|
||||||
|
|
||||||
|
## Target Vault Shape
|
||||||
|
|
||||||
|
This is the rough destination, not a required first commit:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/world/
|
||||||
|
game.md
|
||||||
|
parser.md
|
||||||
|
ui.md
|
||||||
|
mechanics/
|
||||||
|
light.md
|
||||||
|
resolve.md
|
||||||
|
drunk.md
|
||||||
|
inventory.md
|
||||||
|
actions/
|
||||||
|
burn-letter.md
|
||||||
|
drink-whiskey.md
|
||||||
|
rooms/
|
||||||
|
items/
|
||||||
|
encounters/
|
||||||
|
endings/
|
||||||
|
bugs/
|
||||||
|
authoring.md
|
||||||
|
open-source-authoring-roadmap.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`game.md` should become the primary manifest: title, description, starting room, starting inventory, ending priority, enabled mechanics, default UI labels, and other story-level settings.
|
||||||
|
|
||||||
|
`parser.md` should hold author-controlled vocabulary: verbs, aliases, prepositions, direction words, meta commands, and no-target verbs.
|
||||||
|
|
||||||
|
`mechanics/*.md` should describe optional rule modules in markdown. Some mechanics can be fully declarative; others can bind to a named TypeScript handler while keeping all author-facing knobs and prose in markdown.
|
||||||
|
|
||||||
|
`actions/*.md` should replace one-off hardcoded interactions where possible, especially story-specific combinations like burning the letter or drinking whiskey.
|
||||||
|
|
||||||
|
## Phase 1: Manifest in Markdown
|
||||||
|
|
||||||
|
Move story-level constants out of TypeScript and into `src/world/game.md`.
|
||||||
|
|
||||||
|
Candidate frontmatter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: halfstreet
|
||||||
|
title: Half Street
|
||||||
|
description: A gothic mystery.
|
||||||
|
startingRoom: "[[outside-gate]]"
|
||||||
|
startingInventory:
|
||||||
|
- "[[letter]]"
|
||||||
|
- "[[matches]]"
|
||||||
|
- "[[broken-cigarette]]"
|
||||||
|
endingPriority:
|
||||||
|
- mercy
|
||||||
|
- "true"
|
||||||
|
- replacement
|
||||||
|
- bad
|
||||||
|
- wrong
|
||||||
|
transcriptCap: 200
|
||||||
|
```
|
||||||
|
|
||||||
|
Candidate sections:
|
||||||
|
|
||||||
|
```md
|
||||||
|
## opening-art
|
||||||
|
...
|
||||||
|
|
||||||
|
## help
|
||||||
|
...
|
||||||
|
|
||||||
|
## ended
|
||||||
|
The story has ended. Type `restart` or `undo`.
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Add a `parseGame` loader path beside `parseRoom`, `parseItem`, `parseEnding`, and `parseEncounterNarration`.
|
||||||
|
- Update `World` to include manifest data instead of hardcoding `startingRoom`, `startingInventory`, required endings, ending priority, ASCII art, and help copy in engine/UI files.
|
||||||
|
- Keep validation in `src/world/index.ts`: starting room must exist, starting inventory must reference real items, ending priority must reference loaded endings.
|
||||||
|
- Treat this as the first reusable boundary. A fork should be able to change the starting room and opening text without touching TypeScript.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- `src/world/index.ts` no longer hardcodes `outside-gate`, starting items, or ending priority.
|
||||||
|
- Opening ASCII/help text can be edited in Obsidian.
|
||||||
|
- Existing Halfstreet tests and build still pass.
|
||||||
|
|
||||||
|
## Phase 2: Parser Vocabulary in Markdown
|
||||||
|
|
||||||
|
Move parser vocabulary into `src/world/parser.md`.
|
||||||
|
|
||||||
|
Candidate frontmatter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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, hint, save, quit, theme]
|
||||||
|
verbs:
|
||||||
|
go: [go, walk, move]
|
||||||
|
look: [look, l]
|
||||||
|
examine: [examine, x, inspect]
|
||||||
|
take: [take, get, grab, pick up]
|
||||||
|
drop: [drop, put, leave]
|
||||||
|
use: [use, combine]
|
||||||
|
open: [open, uncover]
|
||||||
|
close: [close]
|
||||||
|
drink: [drink, sip]
|
||||||
|
read: [read]
|
||||||
|
light: [light]
|
||||||
|
extinguish: [extinguish, douse]
|
||||||
|
attack: [attack, kill, fight, strike]
|
||||||
|
hold: [hold, show]
|
||||||
|
push: [push, press]
|
||||||
|
pull: [pull]
|
||||||
|
cut: [cut, trim]
|
||||||
|
play: [play]
|
||||||
|
listen: [listen]
|
||||||
|
pour: [pour]
|
||||||
|
wait: [wait, z]
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Keep the parser code generic: it should consume a parsed vocabulary object rather than owning Halfstreet's vocabulary.
|
||||||
|
- Allow multi-word verb aliases from markdown, not a separate hardcoded `TWO_WORD_VERBS` list.
|
||||||
|
- Validate that every verb key maps to an engine-supported verb or a registered custom action.
|
||||||
|
- Keep parser behavior deterministic and testable with a small synthetic vocabulary fixture.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- A new verb alias can be added by editing `parser.md`.
|
||||||
|
- Parser tests cover markdown-loaded vocabulary.
|
||||||
|
- Existing Halfstreet commands continue to parse.
|
||||||
|
|
||||||
|
## Phase 3: Text and System Messages in Markdown
|
||||||
|
|
||||||
|
Move generic narration strings that authors may want to tune into markdown.
|
||||||
|
|
||||||
|
Candidate file: `src/world/messages.md`
|
||||||
|
|
||||||
|
Candidate sections:
|
||||||
|
|
||||||
|
```md
|
||||||
|
## unknown-verb
|
||||||
|
You consider the words, but they don't fit this place.
|
||||||
|
|
||||||
|
## unknown-noun
|
||||||
|
You don't see anything like that here.
|
||||||
|
|
||||||
|
## malformed
|
||||||
|
You hesitate.
|
||||||
|
|
||||||
|
## nothing-to-confirm
|
||||||
|
Nothing to confirm.
|
||||||
|
|
||||||
|
## cancelled
|
||||||
|
Cancelled.
|
||||||
|
|
||||||
|
## inventory-empty
|
||||||
|
You are empty-handed.
|
||||||
|
|
||||||
|
## taken
|
||||||
|
Taken.
|
||||||
|
|
||||||
|
## dropped
|
||||||
|
Dropped.
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Add a small message lookup helper with fallback defaults.
|
||||||
|
- Use message keys from dispatcher and UI instead of inline strings.
|
||||||
|
- Keep heavily stateful narration in mechanics/actions, not in one giant message file.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Common parser/dispatcher responses are author-editable in Obsidian.
|
||||||
|
- Missing message keys fail validation or fall back predictably.
|
||||||
|
|
||||||
|
## Phase 4: Mechanics as Markdown-Configured Modules
|
||||||
|
|
||||||
|
Move optional, reusable systems into `src/world/mechanics/*.md`.
|
||||||
|
|
||||||
|
Start with mechanics that already exist:
|
||||||
|
|
||||||
|
- `light.md`: burn duration, burn trigger policy, item state keys, meter visibility, extinguish behavior, author-facing prose keys.
|
||||||
|
- `resolve.md`: resolve ladder, safe-room recovery, failure behavior.
|
||||||
|
- `drunk.md`: special room prefix, max moves, pass-out room, item reset behavior, visual effect flag.
|
||||||
|
- `inventory.md`: carry limits later, drop policy, lit-drop restrictions.
|
||||||
|
|
||||||
|
Candidate `light.md` frontmatter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
enabled: true
|
||||||
|
handler: light
|
||||||
|
maxTurns: 6
|
||||||
|
burnOn:
|
||||||
|
- move
|
||||||
|
- wait
|
||||||
|
stateKeys:
|
||||||
|
lit: lit
|
||||||
|
burn: burn
|
||||||
|
ui:
|
||||||
|
meter: true
|
||||||
|
icon: candle
|
||||||
|
messages:
|
||||||
|
noLighter: You have nothing to light it with.
|
||||||
|
alreadyLit: It's already lit.
|
||||||
|
notLit: It isn't lit.
|
||||||
|
dropLit: Extinguish it first.
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- TypeScript still owns the actual light algorithm, but markdown owns the knobs and prose.
|
||||||
|
- `World` should expose enabled mechanics as parsed data.
|
||||||
|
- Dispatcher should call enabled mechanic handlers through a small registry rather than direct Halfstreet-specific functions.
|
||||||
|
- Do not overgeneralize on the first pass. Convert one mechanic, keep tests green, then repeat.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The light timer can be changed from markdown.
|
||||||
|
- Disabling a mechanic in markdown removes its behavior cleanly.
|
||||||
|
- Existing light UI still works from parsed mechanic data.
|
||||||
|
|
||||||
|
## Phase 5: Declarative Actions
|
||||||
|
|
||||||
|
Move one-off item interactions into `src/world/actions/*.md` where possible.
|
||||||
|
|
||||||
|
Candidate `burn-letter.md`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: burn-letter
|
||||||
|
verbs: [use, light]
|
||||||
|
requires:
|
||||||
|
allVisibleOrHeld:
|
||||||
|
- "[[letter]]"
|
||||||
|
- "[[matches]]"
|
||||||
|
consumes:
|
||||||
|
inventory:
|
||||||
|
- "[[letter]]"
|
||||||
|
decrements:
|
||||||
|
item: "[[matches]]"
|
||||||
|
stateKey: uses
|
||||||
|
setsFlags:
|
||||||
|
letterBurned: true
|
||||||
|
```
|
||||||
|
|
||||||
|
```md
|
||||||
|
## success
|
||||||
|
The letter catches at one corner. In a few breaths it is ash.
|
||||||
|
|
||||||
|
## spent
|
||||||
|
The matchbook is empty.
|
||||||
|
```
|
||||||
|
|
||||||
|
Candidate `drink-whiskey.md` may need a `handler: drunk-transition` escape hatch because it changes room, removes and resets an item, and starts a timed mode. That is acceptable: markdown should still own the item ids, destination room, max moves, and prose.
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Support a small declarative action grammar first: required items, consumed items, state decrement, flags, room transition, success narration.
|
||||||
|
- Add `handler` as an explicit escape hatch for complex behavior.
|
||||||
|
- Validate all referenced items, rooms, flags, and section keys.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Burning the letter no longer lives as hardcoded ids in the dispatcher.
|
||||||
|
- At least one complex action remains supported through a named handler with markdown-owned config.
|
||||||
|
|
||||||
|
## Phase 6: Encounters Fully in Markdown
|
||||||
|
|
||||||
|
Current state: implemented. Encounter phase machines and prose now live together in `src/world/encounters/*.md`.
|
||||||
|
|
||||||
|
Destination: encounter phase machines live in `src/world/encounters/*.md`.
|
||||||
|
|
||||||
|
Candidate frontmatter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: rat
|
||||||
|
startsIn: "[[cellar-stair]]"
|
||||||
|
initialPhase: lurking
|
||||||
|
aliases: [rat]
|
||||||
|
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
|
||||||
|
- verb: wait
|
||||||
|
chipLabel: WAIT
|
||||||
|
narration: wait-stays
|
||||||
|
to: lurking
|
||||||
|
```
|
||||||
|
|
||||||
|
Sections remain the prose:
|
||||||
|
|
||||||
|
```md
|
||||||
|
## lurking
|
||||||
|
...
|
||||||
|
|
||||||
|
## attack-rat-resolved
|
||||||
|
...
|
||||||
|
|
||||||
|
## wait-stays
|
||||||
|
...
|
||||||
|
|
||||||
|
## wrong-verb
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Replace `encounters.ts` with parsed encounter definitions from markdown.
|
||||||
|
- Validate every `description`, `narration`, and `defaultWrongVerbNarration` key points to a real section.
|
||||||
|
- Keep support for custom handler hooks later if needed, but do not start there.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Adding a simple encounter requires one markdown file only.
|
||||||
|
- No duplicated `startsIn` / `initialPhase` values between markdown and TypeScript.
|
||||||
|
- Encounter transition chips still compute correctly.
|
||||||
|
|
||||||
|
## Phase 7: UI Configuration in Markdown
|
||||||
|
|
||||||
|
Move author-facing UI labels, themes, footer links, title metadata, and feature toggles into `src/world/ui.md`.
|
||||||
|
|
||||||
|
Candidate frontmatter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
pageTitle: Halfstreet - Ethan J Lewis
|
||||||
|
description: A gothic mystery.
|
||||||
|
robots: noindex
|
||||||
|
themeColor: "#1a0d00"
|
||||||
|
footer:
|
||||||
|
copyright: "2026 Ethan J Lewis"
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Keep layout and styling in Astro/CSS.
|
||||||
|
- Let markdown control labels, metadata, and feature switches.
|
||||||
|
- Avoid making authors edit `src/pages/index.astro` for ordinary title/footer/help changes.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Site metadata and footer links are editable from `src/world/ui.md`.
|
||||||
|
- Feature toggles are validated and reflected in the UI.
|
||||||
|
|
||||||
|
## Phase 8: Authoring Docs and Starter Template
|
||||||
|
|
||||||
|
Add docs that live where authors work.
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `src/world/authoring.md`: how to create rooms, items, encounters, endings, mechanics, and actions.
|
||||||
|
- `src/world/templates/room.md`
|
||||||
|
- `src/world/templates/item.md`
|
||||||
|
- `src/world/templates/encounter.md`
|
||||||
|
- `src/world/templates/action.md`
|
||||||
|
- `src/world/templates/game.md`
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
|
||||||
|
- Keep docs short and example-heavy.
|
||||||
|
- Include "common validation errors" with exact fixes.
|
||||||
|
- Add a tiny sample adventure later if the repo is meant to be a toolkit, not only a Halfstreet fork.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- A new author can add a room, item, simple encounter, and ending by following files inside the Obsidian vault.
|
||||||
|
- Templates use wikilinks and valid frontmatter.
|
||||||
|
|
||||||
|
## Phase 9: Package Boundary
|
||||||
|
|
||||||
|
Once the markdown-driven pieces are stable, decide whether to keep this as a forkable app or extract a package.
|
||||||
|
|
||||||
|
Forkable app path:
|
||||||
|
|
||||||
|
- Keep everything in one Astro repo.
|
||||||
|
- Document "replace `src/world` to make your own game."
|
||||||
|
- Lowest maintenance burden.
|
||||||
|
|
||||||
|
Package path:
|
||||||
|
|
||||||
|
- Extract generic engine/loader/parser into a package, with Halfstreet as an example app.
|
||||||
|
- Better for reuse, but increases release and compatibility work.
|
||||||
|
|
||||||
|
Recommendation: stay forkable first. Do not package until at least two different worlds can run through the same engine without code edits.
|
||||||
|
|
||||||
|
## Suggested Implementation Order
|
||||||
|
|
||||||
|
1. Add `game.md` manifest and parse it.
|
||||||
|
2. Move opening/help/ending priority/start state into manifest-backed world data.
|
||||||
|
3. Add `parser.md` and make parser consume loaded vocabulary.
|
||||||
|
4. Add `messages.md` for common system responses.
|
||||||
|
5. Convert the light mechanic to markdown-configured runtime behavior.
|
||||||
|
6. Convert `burn-letter` into a declarative action.
|
||||||
|
7. Convert encounter state machines into markdown.
|
||||||
|
8. Add `ui.md` for metadata, footer, and UI feature toggles.
|
||||||
|
9. Add authoring docs and templates.
|
||||||
|
10. Reassess package extraction after a second sample world exists.
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
- Phase 1 is implemented: `game.md` controls the manifest, starting room, starting inventory, ending priority, transcript cap, opening art, help text, and ended text.
|
||||||
|
- Phase 2 is implemented: `parser.md` controls command vocabulary, direction aliases, prepositions, stop words, no-target verbs, and meta verbs.
|
||||||
|
- Phase 3 is implemented: `messages.md` controls common parser, dispatcher, and UI system responses with TypeScript defaults as fallback.
|
||||||
|
- Phase 4 is implemented for the first reusable mechanics: `mechanics/light.md` controls the light mechanic's enabled state, burn triggers, burn duration, state keys, meter toggle, and light prose fallbacks; `mechanics/resolve.md` controls the resolve ladder, safe-room recovery, wrong-verb cost, and retreat behavior.
|
||||||
|
- Phase 5 has its first two action patterns implemented: `actions/burn-letter.md` defines a simple declarative interaction, and `actions/drink-whiskey.md` defines a complex `drunk-transition` handler-backed action with markdown-owned item ids, destination room, move cap, reset room, wake room, and prose.
|
||||||
|
- Phase 5 validation is hardened: handler-backed actions require their handler-specific prose sections, unsupported handler config is rejected, duplicate handler ownership fails during world assembly, and missing room/item references report the exact action field authors need to fix.
|
||||||
|
- Phase 6 started with an incremental migration: encounter markdown gained full phase machine support when `phases:` is present, and `rat` became the first fully markdown-owned encounter.
|
||||||
|
- Phase 6 now has its first simple batch migrated: `window-guest`, `covered-cage`, and `piano-echo` are markdown-owned, including aliases, resolved/failed effects, wrong-verb text, transition chips, and transition commands.
|
||||||
|
- Phase 6 also has the wait-resolved batch migrated: `breathing-wall`, `linen-shape`, and `stair-sleeper` are markdown-owned, including aliases, resolved/failed effects, wrong-verb text, and wait transition chips.
|
||||||
|
- Phase 6 now has an item-gated batch migrated: `ivy-figure`, `child-beneath-well`, and `bone-keeper` are markdown-owned, including required inventory items, repeated narration keys, transition-level flags, alternate fallback transitions, and resolved/failed effects.
|
||||||
|
- Phase 6 now has a garden/lower-passage batch migrated: `garden-procession`, `reflection`, and `root-movement` are markdown-owned, including wait, item-gated, and listen transitions plus resolved/failed effects.
|
||||||
|
- Phase 6 is implemented: the final legacy batch (`portrait-woman`, `basilisk`, `vault-memory`, `creaking-floorboard`, `distant-steps`, and `rainwater-basin`) is markdown-owned, `encounters.ts` has been removed, and world assembly now requires every encounter markdown file to define `phases:`.
|
||||||
|
- Phase 7 is implemented: `ui.md` controls page title, meta description, robots, theme color, footer copyright/link/build labels, and feature toggles for chips, the light meter, typed text, and room-title scrolling.
|
||||||
|
- Phase 8 is implemented: `authoring.md` explains the markdown authoring workflow, common validation errors, and current room/item/encounter/ending/mechanic/action surfaces; `templates/` includes starter room, item, encounter, action, and game files.
|
||||||
|
- Phase 9 is resolved for now in favor of the forkable app path: README and vault-local authoring docs tell authors to replace `src/world` while keeping the Astro app and TypeScript runtime intact. Package extraction is deferred until a second sample world proves the boundary.
|
||||||
|
|
||||||
|
## Near-Term Slice
|
||||||
|
|
||||||
|
The next concrete slice should stay on open-source polish without extracting a package:
|
||||||
|
|
||||||
|
- Add a short root-level contribution guide if outside authors are expected to send changes.
|
||||||
|
- Decide whether to include a tiny second sample world fixture to test the forkable boundary.
|
||||||
|
- Keep package extraction deferred until that sample world can run without Halfstreet-specific code edits.
|
||||||
|
|
||||||
|
This keeps the toolkit roadmap focused on a low-maintenance open-source shape before adding package complexity.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
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, hint, save, quit, theme]
|
||||||
|
verbs:
|
||||||
|
go: [go, walk, move]
|
||||||
|
look: [look, l]
|
||||||
|
examine: [examine, x, inspect]
|
||||||
|
inventory: [inventory, inv, i]
|
||||||
|
take: [take, get, grab, pick up]
|
||||||
|
drop: [drop, put, leave]
|
||||||
|
use: [use, combine]
|
||||||
|
open: [open, uncover]
|
||||||
|
close: [close]
|
||||||
|
drink: [drink, sip]
|
||||||
|
read: [read]
|
||||||
|
light: [light]
|
||||||
|
extinguish: [extinguish, douse]
|
||||||
|
attack: [attack, kill, fight, strike]
|
||||||
|
hold: [hold, show]
|
||||||
|
push: [push, press]
|
||||||
|
pull: [pull]
|
||||||
|
cut: [cut, trim]
|
||||||
|
play: [play]
|
||||||
|
listen: [listen]
|
||||||
|
pour: [pour]
|
||||||
|
wait: [wait, z]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Parser Vocabulary
|
||||||
|
|
||||||
|
Edit the frontmatter above to tune the commands Halfstreet accepts.
|
||||||
+226
-3
@@ -1,5 +1,202 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
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', () => {
|
describe('roomFrontmatterSchema', () => {
|
||||||
it('accepts a fully populated room', () => {
|
it('accepts a fully populated room', () => {
|
||||||
@@ -67,9 +264,9 @@ describe('endingFrontmatterSchema', () => {
|
|||||||
expect(() => endingFrontmatterSchema.parse(data)).not.toThrow()
|
expect(() => endingFrontmatterSchema.parse(data)).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects unknown ending id', () => {
|
it('accepts custom ending ids', () => {
|
||||||
const data = { id: 'secret', whenFlags: {} }
|
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' }
|
const data = { id: 'rat', startsIn: 'cellar-stair', initialPhase: 'lurking' }
|
||||||
expect(() => encounterFrontmatterSchema.parse(data)).not.toThrow()
|
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', () => {
|
describe('itemFrontmatterSchema — bible additions', () => {
|
||||||
|
|||||||
+202
-1
@@ -1,7 +1,178 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { SUPPORTED_META_VERBS, SUPPORTED_VERBS } from '../engine/parser'
|
||||||
|
import { RESOLVE_LEVELS } from '../engine/types'
|
||||||
|
|
||||||
const stateValueSchema = z.union([z.string(), z.boolean(), z.number(), z.array(z.string())])
|
const stateValueSchema = z.union([z.string(), z.boolean(), z.number(), z.array(z.string())])
|
||||||
const stateRecordSchema = z.record(z.string(), stateValueSchema)
|
const stateRecordSchema = z.record(z.string(), stateValueSchema)
|
||||||
|
const directionSchema = z.enum(['n', 's', 'e', 'w', 'u', 'd'])
|
||||||
|
const verbSchema = z.enum(SUPPORTED_VERBS)
|
||||||
|
const metaVerbSchema = z.enum(SUPPORTED_META_VERBS)
|
||||||
|
const aliasListSchema = z.array(z.string().trim().min(1)).min(1)
|
||||||
|
const lightBurnTriggerSchema = z.enum(['move', 'wait'])
|
||||||
|
const resolveLevelSchema = z.enum(RESOLVE_LEVELS)
|
||||||
|
const resolveCostSchema = z.union([z.literal(0), z.literal(1), z.literal(2)])
|
||||||
|
|
||||||
|
export const gameFrontmatterSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string().min(1),
|
||||||
|
startingRoom: z.string().min(1),
|
||||||
|
startingInventory: z.array(z.string().min(1)).default([]),
|
||||||
|
endingPriority: z.array(z.string().min(1)).min(1),
|
||||||
|
transcriptCap: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type GameFrontmatter = z.infer<typeof gameFrontmatterSchema>
|
||||||
|
|
||||||
|
export const parserFrontmatterSchema = z.object({
|
||||||
|
directions: z.record(directionSchema, aliasListSchema),
|
||||||
|
prepositions: aliasListSchema,
|
||||||
|
stopWords: z.array(z.string().trim().min(1)).default([]),
|
||||||
|
noTargetVerbs: z.array(verbSchema).default([]),
|
||||||
|
metaVerbs: z.array(metaVerbSchema).default([]),
|
||||||
|
verbs: z.partialRecord(verbSchema, aliasListSchema),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
for (const direction of directionSchema.options) {
|
||||||
|
if (!value.directions[direction]?.length) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['directions', direction],
|
||||||
|
message: `directions.${direction} must define at least one alias`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ParserFrontmatter = z.infer<typeof parserFrontmatterSchema>
|
||||||
|
|
||||||
|
export const uiFrontmatterSchema = z.object({
|
||||||
|
pageTitle: z.string().trim().min(1),
|
||||||
|
description: z.string().trim().min(1),
|
||||||
|
robots: z.string().trim().min(1).default('noindex'),
|
||||||
|
themeColor: z.string().trim().min(1).default('#1a0d00'),
|
||||||
|
footer: z.object({
|
||||||
|
copyright: z.string().trim().min(1),
|
||||||
|
copyrightHref: z.url().optional(),
|
||||||
|
buildLabel: z.string().trim().min(1).default('Build #'),
|
||||||
|
showBuild: z.boolean().default(true),
|
||||||
|
links: z.array(z.object({
|
||||||
|
label: z.string().trim().min(1),
|
||||||
|
href: z.url(),
|
||||||
|
})).default([]),
|
||||||
|
}),
|
||||||
|
features: z.object({
|
||||||
|
chips: z.boolean().default(true),
|
||||||
|
lightMeter: z.boolean().default(true),
|
||||||
|
typedEffect: z.boolean().default(true),
|
||||||
|
roomScroll: z.boolean().default(true),
|
||||||
|
}).default({
|
||||||
|
chips: true,
|
||||||
|
lightMeter: true,
|
||||||
|
typedEffect: true,
|
||||||
|
roomScroll: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type UiFrontmatter = z.infer<typeof uiFrontmatterSchema>
|
||||||
|
|
||||||
|
export const lightMechanicFrontmatterSchema = z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
handler: z.literal('light').default('light'),
|
||||||
|
maxTurns: z.number().int().positive().default(6),
|
||||||
|
burnOn: z.array(lightBurnTriggerSchema).default(['move', 'wait']),
|
||||||
|
stateKeys: z.object({
|
||||||
|
lit: z.string().trim().min(1).default('lit'),
|
||||||
|
burn: z.string().trim().min(1).default('burn'),
|
||||||
|
}).default({ lit: 'lit', burn: 'burn' }),
|
||||||
|
ui: z.object({
|
||||||
|
meter: z.boolean().default(true),
|
||||||
|
icon: z.string().trim().min(1).default('candle'),
|
||||||
|
}).default({ meter: true, icon: 'candle' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type LightMechanicFrontmatter = z.infer<typeof lightMechanicFrontmatterSchema>
|
||||||
|
|
||||||
|
export const resolveMechanicFrontmatterSchema = z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
handler: z.literal('resolve').default('resolve'),
|
||||||
|
ladder: z.array(resolveLevelSchema).min(1).default([...RESOLVE_LEVELS]),
|
||||||
|
wrongVerbCost: resolveCostSchema.default(1),
|
||||||
|
safeRooms: z.object({
|
||||||
|
recoverySteps: z.number().int().nonnegative().default(1),
|
||||||
|
}).default({ recoverySteps: 1 }),
|
||||||
|
failure: z.object({
|
||||||
|
retreatAt: resolveLevelSchema.default('returning'),
|
||||||
|
afterRetreat: resolveLevelSchema.default('shaken'),
|
||||||
|
}).default({ retreatAt: 'returning', afterRetreat: 'shaken' }),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
const seen = new Set(value.ladder)
|
||||||
|
if (seen.size !== value.ladder.length) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['ladder'],
|
||||||
|
message: 'ladder entries must be unique',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!seen.has(value.failure.retreatAt)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['failure', 'retreatAt'],
|
||||||
|
message: 'failure.retreatAt must be present in ladder',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!seen.has(value.failure.afterRetreat)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['failure', 'afterRetreat'],
|
||||||
|
message: 'failure.afterRetreat must be present in ladder',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ResolveMechanicFrontmatter = z.infer<typeof resolveMechanicFrontmatterSchema>
|
||||||
|
|
||||||
|
const actionHandlerSchema = z.enum(['drunk-transition'])
|
||||||
|
|
||||||
|
export const actionFrontmatterSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
verbs: z.array(verbSchema).min(1),
|
||||||
|
handler: actionHandlerSchema.optional(),
|
||||||
|
requires: z.object({
|
||||||
|
allHeld: z.array(z.string().min(1)).min(1).optional(),
|
||||||
|
allVisibleOrHeld: z.array(z.string().min(1)).min(1).optional(),
|
||||||
|
}).optional(),
|
||||||
|
consumes: z.object({
|
||||||
|
inventory: z.array(z.string().min(1)).default([]),
|
||||||
|
}).optional(),
|
||||||
|
decrements: z.object({
|
||||||
|
item: z.string().min(1),
|
||||||
|
stateKey: z.string().min(1),
|
||||||
|
}).optional(),
|
||||||
|
setsFlags: stateRecordSchema.optional(),
|
||||||
|
drunkTransition: z.object({
|
||||||
|
destinationRoom: z.string().min(1),
|
||||||
|
maxMoves: z.number().int().positive(),
|
||||||
|
wakeRoom: z.string().min(1),
|
||||||
|
resetRoom: z.string().min(1),
|
||||||
|
}).optional(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
if (value.handler === 'drunk-transition' && !value.drunkTransition) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['drunkTransition'],
|
||||||
|
message: 'drunkTransition is required when handler is drunk-transition',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (value.handler !== 'drunk-transition' && value.drunkTransition) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['drunkTransition'],
|
||||||
|
message: 'drunkTransition is only supported when handler is drunk-transition',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ActionFrontmatter = z.infer<typeof actionFrontmatterSchema>
|
||||||
|
|
||||||
export const roomFrontmatterSchema = z.object({
|
export const roomFrontmatterSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
@@ -46,7 +217,7 @@ export const itemFrontmatterSchema = z.object({
|
|||||||
export type ItemFrontmatter = z.infer<typeof itemFrontmatterSchema>
|
export type ItemFrontmatter = z.infer<typeof itemFrontmatterSchema>
|
||||||
|
|
||||||
export const endingFrontmatterSchema = z.object({
|
export const endingFrontmatterSchema = z.object({
|
||||||
id: z.enum(['true', 'wrong', 'bad', 'replacement', 'mercy']),
|
id: z.string().min(1),
|
||||||
whenFlags: stateRecordSchema.default({}),
|
whenFlags: stateRecordSchema.default({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -56,6 +227,36 @@ export const encounterFrontmatterSchema = z.object({
|
|||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
startsIn: z.string().min(1),
|
startsIn: z.string().min(1),
|
||||||
initialPhase: z.string().min(1),
|
initialPhase: z.string().min(1),
|
||||||
|
aliases: z.array(z.string().min(1)).optional(),
|
||||||
|
onResolved: z.object({
|
||||||
|
setFlags: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||||
|
unlockExits: z.array(z.object({
|
||||||
|
room: z.string().min(1),
|
||||||
|
direction: directionSchema,
|
||||||
|
})).optional(),
|
||||||
|
}).optional(),
|
||||||
|
onFailed: z.object({
|
||||||
|
narration: z.string().min(1),
|
||||||
|
retreatTo: z.string().min(1),
|
||||||
|
}).optional(),
|
||||||
|
defaultWrongVerbNarration: z.string().min(1).optional(),
|
||||||
|
phases: z.record(z.string().min(1), z.object({
|
||||||
|
description: z.string().min(1),
|
||||||
|
transitions: z.array(z.object({
|
||||||
|
verb: verbSchema,
|
||||||
|
target: z.string().min(1).optional(),
|
||||||
|
chipLabel: z.string().min(1).optional(),
|
||||||
|
chipCommand: z.string().min(1).optional(),
|
||||||
|
requires: z.object({
|
||||||
|
item: z.string().min(1),
|
||||||
|
state: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||||
|
}).optional(),
|
||||||
|
to: z.string().min(1),
|
||||||
|
narration: z.string().min(1),
|
||||||
|
resolveCost: resolveCostSchema.optional(),
|
||||||
|
setFlags: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||||
|
})).default([]),
|
||||||
|
})).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type EncounterFrontmatter = z.infer<typeof encounterFrontmatterSchema>
|
export type EncounterFrontmatter = z.infer<typeof encounterFrontmatterSchema>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
id: sample-action
|
||||||
|
verbs: [use]
|
||||||
|
requires:
|
||||||
|
allVisibleOrHeld:
|
||||||
|
- "[[sample-item]]"
|
||||||
|
setsFlags:
|
||||||
|
sampleActionDone: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## success
|
||||||
|
The action succeeds.
|
||||||
|
|
||||||
|
## missingRequired
|
||||||
|
You do not see what you need.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
id: sample-encounter
|
||||||
|
startsIn: "[[sample-room]]"
|
||||||
|
initialPhase: waiting
|
||||||
|
aliases: [figure]
|
||||||
|
onResolved:
|
||||||
|
setFlags:
|
||||||
|
sampleEncounterResolved: true
|
||||||
|
defaultWrongVerbNarration: wrong-verb
|
||||||
|
phases:
|
||||||
|
waiting:
|
||||||
|
description: waiting
|
||||||
|
transitions:
|
||||||
|
- verb: wait
|
||||||
|
chipLabel: WAIT
|
||||||
|
chipCommand: wait
|
||||||
|
narration: waited
|
||||||
|
to: resolved
|
||||||
|
---
|
||||||
|
|
||||||
|
## waiting
|
||||||
|
The figure waits.
|
||||||
|
|
||||||
|
## waited
|
||||||
|
You wait. The figure is gone.
|
||||||
|
|
||||||
|
## wrong-verb
|
||||||
|
That does not change what waits here.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
id: sample-game
|
||||||
|
title: Sample Game
|
||||||
|
description: A small text adventure.
|
||||||
|
startingRoom: "[[sample-room]]"
|
||||||
|
startingInventory: []
|
||||||
|
endingPriority:
|
||||||
|
- sample-ending
|
||||||
|
transcriptCap: 200
|
||||||
|
---
|
||||||
|
|
||||||
|
## opening-art
|
||||||
|
SAMPLE GAME
|
||||||
|
|
||||||
|
## help
|
||||||
|
Type short commands to act in the world.
|
||||||
|
|
||||||
|
Common commands:
|
||||||
|
look
|
||||||
|
n, s, e, w, u, d
|
||||||
|
take item
|
||||||
|
examine item
|
||||||
|
inventory
|
||||||
|
wait
|
||||||
|
undo
|
||||||
|
restart
|
||||||
|
|
||||||
|
## ended
|
||||||
|
The story has ended. Type `restart` or `undo`.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
id: sample-item
|
||||||
|
names: ["sample item", "item"]
|
||||||
|
short: "a sample item"
|
||||||
|
takeable: true
|
||||||
|
initialState: {}
|
||||||
|
---
|
||||||
|
|
||||||
|
Describe the item when the player examines it.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
id: sample-room
|
||||||
|
title: "[ Sample Room ]"
|
||||||
|
exitN: null
|
||||||
|
exitS: null
|
||||||
|
exitE: null
|
||||||
|
exitW: null
|
||||||
|
exitU: null
|
||||||
|
exitD: null
|
||||||
|
items: []
|
||||||
|
encounter: null
|
||||||
|
safe: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## first-visit
|
||||||
|
Describe what the player notices the first time they enter.
|
||||||
|
|
||||||
|
## revisit
|
||||||
|
Describe the room after it has already been seen.
|
||||||
|
|
||||||
|
## examined
|
||||||
|
Describe closer inspection of the room.
|
||||||
+171
-8
@@ -1,7 +1,169 @@
|
|||||||
// World data type definitions. World modules export plain data conforming to
|
// World data type definitions. World modules export plain data conforming to
|
||||||
// these shapes; the engine reads but never mutates them.
|
// these shapes; the engine reads but never mutates them.
|
||||||
|
|
||||||
import type { RoomId, ItemId, EncounterId, Direction, Verb, EncounterPhase } from '../engine/types'
|
import type { RoomId, ItemId, EncounterId, Direction, Verb, EncounterPhase, ResolveLevel } from '../engine/types'
|
||||||
|
import type { ParserVocabulary } from '../engine/parser'
|
||||||
|
|
||||||
|
export const DEFAULT_WORLD_MESSAGES = {
|
||||||
|
'unknown-verb': "You consider the words, but they don't fit this place.",
|
||||||
|
'unknown-noun': "You don't see anything like that here.",
|
||||||
|
malformed: 'You hesitate.',
|
||||||
|
'nothing-to-confirm': 'Nothing to confirm.',
|
||||||
|
cancelled: 'Cancelled.',
|
||||||
|
'nothing-to-choose': 'Nothing to choose between.',
|
||||||
|
'no-undo': 'There is no further back.',
|
||||||
|
'use-lighter-with-what': 'Use match with what?',
|
||||||
|
'use-unknown': "You can't think how to use that here.",
|
||||||
|
'nothing-happens': 'Nothing happens.',
|
||||||
|
nowhere: 'You are nowhere.',
|
||||||
|
'no-exit': "You can't go that way.",
|
||||||
|
'unfinished-exit': 'The way ahead is unfinished.',
|
||||||
|
'cannot-drink': "You can't drink that.",
|
||||||
|
'need-carrying': "You'd have to be carrying it.",
|
||||||
|
'time-passes': 'Time passes.',
|
||||||
|
listen: 'You listen. The house listens back.',
|
||||||
|
'see-nothing': 'You see nothing.',
|
||||||
|
'inventory-empty': 'You are empty-handed.',
|
||||||
|
'inventory-heading': 'You are carrying:',
|
||||||
|
'dont-see-here': "You don't see that here.",
|
||||||
|
'cannot-take': "You can't take that.",
|
||||||
|
'already-have': 'You already have it.',
|
||||||
|
taken: 'Taken.',
|
||||||
|
'dont-have': "You don't have that.",
|
||||||
|
'drop-lit': 'Extinguish it first.',
|
||||||
|
dropped: 'Dropped.',
|
||||||
|
'dont-see-anything': "You don't see anything like that.",
|
||||||
|
'nothing-to-read': "There's nothing to read on it.",
|
||||||
|
'cannot-light': "You can't light that.",
|
||||||
|
'already-lit': "It's already lit.",
|
||||||
|
'not-helpful': "That isn't going to help.",
|
||||||
|
spent: 'It is spent.',
|
||||||
|
'no-lighter': 'You have nothing to light it with.',
|
||||||
|
'cannot-extinguish': "You can't extinguish that.",
|
||||||
|
'not-lit': "It isn't lit.",
|
||||||
|
'flame-catches': 'It catches.',
|
||||||
|
'flame-dies': 'The flame dies.',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type WorldMessageKey = keyof typeof DEFAULT_WORLD_MESSAGES
|
||||||
|
export type WorldMessages = Partial<Record<WorldMessageKey, string>>
|
||||||
|
|
||||||
|
export interface GameManifest {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
startingRoom: RoomId
|
||||||
|
startingInventory: ItemId[]
|
||||||
|
endingPriority: string[]
|
||||||
|
transcriptCap?: number
|
||||||
|
openingArt?: string
|
||||||
|
helpText?: string
|
||||||
|
endedText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UiConfig {
|
||||||
|
pageTitle: string
|
||||||
|
description: string
|
||||||
|
robots: string
|
||||||
|
themeColor: string
|
||||||
|
footer: {
|
||||||
|
copyright: string
|
||||||
|
copyrightHref?: string
|
||||||
|
buildLabel: string
|
||||||
|
showBuild: boolean
|
||||||
|
links: Array<{
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
features: {
|
||||||
|
chips: boolean
|
||||||
|
lightMeter: boolean
|
||||||
|
typedEffect: boolean
|
||||||
|
roomScroll: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LightBurnTrigger = 'move' | 'wait'
|
||||||
|
export type LightMechanicMessageKey =
|
||||||
|
| 'useLighterWithWhat'
|
||||||
|
| 'cannotLight'
|
||||||
|
| 'alreadyLit'
|
||||||
|
| 'notHelpful'
|
||||||
|
| 'spent'
|
||||||
|
| 'noLighter'
|
||||||
|
| 'cannotExtinguish'
|
||||||
|
| 'notLit'
|
||||||
|
| 'dropLit'
|
||||||
|
| 'flameCatches'
|
||||||
|
| 'flameDies'
|
||||||
|
|
||||||
|
export interface LightMechanicConfig {
|
||||||
|
enabled: boolean
|
||||||
|
handler: 'light'
|
||||||
|
maxTurns: number
|
||||||
|
burnOn: LightBurnTrigger[]
|
||||||
|
stateKeys: {
|
||||||
|
lit: string
|
||||||
|
burn: string
|
||||||
|
}
|
||||||
|
ui?: {
|
||||||
|
meter?: boolean
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
messages?: Partial<Record<LightMechanicMessageKey, string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveMechanicConfig {
|
||||||
|
enabled: boolean
|
||||||
|
handler: 'resolve'
|
||||||
|
ladder: ResolveLevel[]
|
||||||
|
wrongVerbCost: 0 | 1 | 2
|
||||||
|
safeRooms: {
|
||||||
|
recoverySteps: number
|
||||||
|
}
|
||||||
|
failure: {
|
||||||
|
retreatAt: ResolveLevel
|
||||||
|
afterRetreat: ResolveLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorldMechanics {
|
||||||
|
light?: LightMechanicConfig
|
||||||
|
resolve?: ResolveMechanicConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeclarativeAction {
|
||||||
|
id: string
|
||||||
|
verbs: Verb[]
|
||||||
|
handler?: 'drunk-transition'
|
||||||
|
requires?: {
|
||||||
|
allHeld?: ItemId[]
|
||||||
|
allVisibleOrHeld?: ItemId[]
|
||||||
|
}
|
||||||
|
consumes?: {
|
||||||
|
inventory?: ItemId[]
|
||||||
|
}
|
||||||
|
decrements?: {
|
||||||
|
item: ItemId
|
||||||
|
stateKey: string
|
||||||
|
}
|
||||||
|
setsFlags?: Record<string, string | boolean | number | string[]>
|
||||||
|
drunkTransition?: {
|
||||||
|
destinationRoom: RoomId
|
||||||
|
maxMoves: number
|
||||||
|
wakeRoom: RoomId
|
||||||
|
resetRoom: RoomId
|
||||||
|
}
|
||||||
|
messages: {
|
||||||
|
success: string
|
||||||
|
spent?: string
|
||||||
|
missingRequired?: string
|
||||||
|
secretFoundPassOut?: string
|
||||||
|
tooManyMovesPassOut?: string
|
||||||
|
reset?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface RoomDescriptions {
|
export interface RoomDescriptions {
|
||||||
/** Shown the first time the player enters this room. */
|
/** Shown the first time the player enters this room. */
|
||||||
@@ -100,17 +262,18 @@ export interface EncounterDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface World {
|
export interface World {
|
||||||
|
game?: GameManifest
|
||||||
|
ui?: UiConfig
|
||||||
|
parser?: ParserVocabulary
|
||||||
|
messages?: WorldMessages
|
||||||
|
mechanics?: WorldMechanics
|
||||||
|
actions?: Record<string, DeclarativeAction>
|
||||||
startingRoom: RoomId
|
startingRoom: RoomId
|
||||||
startingInventory: ItemId[]
|
startingInventory: ItemId[]
|
||||||
|
endingPriority?: string[]
|
||||||
rooms: Record<RoomId, Room>
|
rooms: Record<RoomId, Room>
|
||||||
items: Record<ItemId, Item>
|
items: Record<ItemId, Item>
|
||||||
encounters: Record<EncounterId, EncounterDef>
|
encounters: Record<EncounterId, EncounterDef>
|
||||||
/** Story flag definitions and the endings they unlock. */
|
/** Story flag definitions and the endings they unlock. */
|
||||||
endings: {
|
endings: Record<string, { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }>
|
||||||
true: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
|
|
||||||
wrong: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
|
|
||||||
bad: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
|
|
||||||
replacement?: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
|
|
||||||
mercy?: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI
|
||||||
|
|
||||||
|
This file controls site metadata, footer links, and runtime UI feature switches.
|
||||||
Reference in New Issue
Block a user