feat(world): expand Halfstreet content slices
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-05-12 14:48:19 -05:00
parent 26dd91947f
commit cc98aa180b
48 changed files with 951 additions and 139 deletions
+1
View File
@@ -178,6 +178,7 @@ export function dispatch(state: GameState, command: ParsedCommand, 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 === '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.kind === 'verb-target') {
+49 -1
View File
@@ -10,7 +10,7 @@ const world: World = {
id: 'foyer',
title: '[ Foyer ]',
descriptions: { firstVisit: 'Foyer.', revisit: 'Foyer.', examined: 'Foyer.' },
exits: { n: 'stair' },
exits: { n: 'stair', e: 'chapel' },
items: [],
safe: true,
},
@@ -29,10 +29,19 @@ const world: World = {
exits: { u: 'stair' },
items: [],
},
chapel: {
id: 'chapel',
title: '[ Chapel ]',
descriptions: { firstVisit: 'Chapel.', revisit: 'Chapel.', examined: 'Chapel.' },
exits: { s: 'foyer' },
items: ['vial'],
encounter: 'basilisk',
},
},
items: {
mirror: { id: 'mirror', names: ['mirror', 'tarnished mirror'], short: 'a tarnished mirror', long: 'A small mirror, tarnished black.', initialState: {}, takeable: true },
sword: { id: 'sword', names: ['sword', 'cane sword'], short: 'a cane sword', long: 'A slim cane sword.', initialState: {}, takeable: true },
vial: { id: 'vial', names: ['vial'], short: 'a vial', long: 'A small vial.', initialState: {}, takeable: true },
},
encounters: {
revenant: {
@@ -59,6 +68,22 @@ const world: World = {
onFailed: { narration: 'You stagger back.', retreatTo: 'foyer' },
defaultWrongVerbNarration: 'The revenant does not seem to notice.',
},
basilisk: {
id: 'basilisk',
aliases: ['basilisk'],
startsIn: 'chapel',
initialPhase: 'sleeping',
phases: {
sleeping: {
description: 'An eye opens beneath the altar.',
transitions: [
{ verb: 'pour', target: 'vial', requires: { item: 'vial' }, narration: 'The eye closes.', to: 'resolved' },
],
},
},
onResolved: { setFlags: { basiliskSpared: true } },
defaultWrongVerbNarration: 'The eye watches.',
},
},
endings: {
true: { whenFlags: { _never: true }, narration: '' },
@@ -116,4 +141,27 @@ describe('encounters — phase advancement', () => {
s = dispatch(s, { kind: 'go', direction: 's' }, world).state
expect(s.resolveLevel).toBe('steady')
})
it('allows a required item to be the direct target in a target-preposition encounter command', () => {
let s = initialStateFor(world)
s = {
...s,
inventory: [...s.inventory, { id: 'vial', state: {} }],
roomState: { ...s.roomState, chapel: { takenItems: ['vial'] } },
}
s = dispatch(s, { kind: 'go', direction: 'e' }, world).state
const r = dispatch(
s,
{
kind: 'verb-target-prep',
verb: 'pour',
target: { canonical: 'vial', raw: 'vial' },
preposition: 'on',
indirect: { canonical: 'basilisk', raw: 'basilisk' },
},
world,
)
expect(r.state.flags['basiliskSpared']).toBe(true)
expect(r.appended.some((l) => l.text.includes('eye closes'))).toBe(true)
})
})
+1 -1
View File
@@ -96,7 +96,7 @@ export function applyVerbToEncounter(
}
}
}
if (t.requires && instrumentId && t.requires.item !== instrumentId) return false
if (t.requires && instrumentId && t.requires.item !== instrumentId && t.requires.item !== targetId) return false
return true
})
+22
View File
@@ -16,6 +16,10 @@ describe('parser — verb-only commands', () => {
expect(parse('look', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'look' })
})
it('recognizes bare "listen"', () => {
expect(parse('listen', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'listen' })
})
it('recognizes bare "inventory" and short forms', () => {
expect(parse('inventory', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
expect(parse('inv', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
@@ -100,6 +104,24 @@ describe('parser — verb + target', () => {
})
})
it('recognizes pour commands with an indirect target', () => {
const ctx: ParserContext = {
knownItems: ['silver-vial'],
knownEncounters: ['basilisk'],
visibleNouns: [{ id: 'basilisk', aliases: ['basilisk'] }],
inventoryItemIds: ['silver-vial'],
lastNoun: null,
awaitingDisambiguation: null,
}
expect(parse('pour silver-vial on basilisk', ctx)).toEqual({
kind: 'verb-target-prep',
verb: 'pour',
target: { canonical: 'silver-vial', raw: 'silver-vial' },
preposition: 'on',
indirect: { canonical: 'basilisk', raw: 'basilisk' },
})
})
it('resolves a single visible noun', () => {
const ctx: ParserContext = {
knownItems: ['torch'],
+4 -2
View File
@@ -34,6 +34,8 @@ const VERB_SYNONYMS: Record<string, Verb> = {
pull: 'pull',
cut: 'cut', trim: 'cut',
play: 'play',
listen: 'listen',
pour: 'pour',
uncover: 'open',
wait: 'wait', z: 'wait',
}
@@ -57,7 +59,7 @@ const META_VERBS: Record<string, MetaVerb> = {
}
/** Verbs that legally take no target. */
const VERB_ONLY_VERBS = new Set<string>(['look', 'inventory', 'wait'])
const VERB_ONLY_VERBS = new Set<string>(['look', 'inventory', 'wait', 'listen'])
/** Two-word verb prefixes (e.g. "pick up X"). */
const TWO_WORD_VERBS = ['pick up']
@@ -157,7 +159,7 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
if (rest.length === 0) {
if (VERB_ONLY_VERBS.has(verb)) {
return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' }
return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' | 'listen' }
}
return { kind: 'unknown', raw: trimmed, reason: 'malformed' }
}
+94
View File
@@ -128,4 +128,98 @@ describe('playthrough — sample world', () => {
expect(state.location).toBe('attic')
expect(state.inventory.map((i) => i.id)).toContain('toy-dog')
})
it('plays through the garden and grounds slice', () => {
const state = play([
'n', // gate → foyer
'n', // foyer → hallway
'u', // hallway → parlor
'u', // parlor → upper stair
'wait',
'u', // upper stair → bedroom
'e', // bedroom → nursery
'take dog',
'w',
'd', // bedroom → upper stair
'd', // upper stair → parlor
'd', // parlor → hallway
'n', // hallway → dining-room
'close curtains',
'e', // dining-room → kitchen
'e', // kitchen → back-door
'e', // back-door → garden
'wait',
'n', // garden → well
'd', // well → well-shaft
'hold dog',
])
expect(state.flags['garden-procession.resolved']).toBe(true)
expect(state.flags['child-beneath-well.resolved']).toBe(true)
expect(state.flags['gardenQuiet']).toBe(true)
expect(state.flags['childPassedWell']).toBe(true)
expect(state.location).toBe('well-shaft')
})
it('plays through the lower-passages slice', () => {
const state = play([
'n', // gate → foyer
'n', // foyer → hallway
'n', // hallway → dining-room
'close curtains',
'n', // dining-room → conservatory
'take shears',
'cut vines with shears',
's', // conservatory → dining-room
'w', // dining-room → hallway
'd', // hallway → music-room
'play note',
'n', // music-room → servants-passage
'wait',
'e', // servants-passage → laundry
'wait',
'take damp sheet',
'w', // laundry → servants-passage
's', // servants-passage → music-room
'u', // music-room → hallway
'n', // hallway → dining-room
'e', // dining-room → kitchen
'e', // kitchen → back-door
'e', // back-door → garden
'wait',
'n', // garden → well
'd', // well → well-shaft
'wait',
'd', // well-shaft → tunnel
'n', // tunnel → ossuary
'take ring',
'leave ring',
'e', // ossuary → flooded-passage
'use water with sheet',
'take boat',
'n', // flooded-passage → root-chamber
'listen',
'e', // root-chamber → burial-gallery
'examine portraits',
'take register',
'read register',
'e', // burial-gallery → antechamber
'e', // antechamber → vault
])
expect(state.flags['bone-keeper.resolved']).toBe(true)
expect(state.flags['reflection.resolved']).toBe(true)
expect(state.flags['root-movement.resolved']).toBe(true)
expect(state.flags['portrait-woman.resolved']).toBe(true)
expect(state.flags['burialRingPlaced']).toBe(true)
expect(state.flags['reflectionObscured']).toBe(true)
expect(state.flags['rootsListenedTo']).toBe(true)
expect(state.flags['familyResemblanceSeen']).toBe(true)
expect(state.location).toBe('vault')
expect(state.inventory.map((i) => i.id)).toEqual(expect.arrayContaining([
'damp-sheet',
'toy-boat',
'family-register',
]))
})
})
+2 -2
View File
@@ -9,7 +9,7 @@ export type Direction = 'n' | 's' | 'e' | 'w' | 'u' | 'd'
export type Verb =
| 'go' | 'look' | 'examine' | 'take' | 'drop' | 'use' | 'open' | 'close'
| 'read' | 'light' | 'extinguish' | 'attack' | 'inventory' | 'wait'
| 'hold' | 'push' | 'pull' | 'cut' | 'play'
| 'hold' | 'push' | 'pull' | 'cut' | 'play' | 'listen' | 'pour'
export type MetaVerb = 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'
@@ -21,7 +21,7 @@ export interface NounRef {
}
export type ParsedCommand =
| { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' }
| { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' | 'listen' }
| { kind: 'verb-target'; verb: Verb; target: NounRef }
| { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef }
| { kind: 'ambiguous'; verb: Verb; rawNoun: string; candidates: string[] }