Implement match interactions and wait chip
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-05-10 07:56:31 -05:00
parent 83e4877852
commit d56c0c8363
7 changed files with 191 additions and 14 deletions
+80
View File
@@ -202,6 +202,29 @@ describe('dispatcher — examine', () => {
const r = dispatch(s, { kind: 'verb-target', verb: 'examine', target: { canonical: 'torch', raw: 'torch' } }, world)
expect(r.appended.some((l) => l.text.includes('iron oil lamp'))).toBe(true)
})
it('uses live match count when examining matches', () => {
const matchWorld: World = {
...world,
startingInventory: ['matches'],
items: {
...world.items,
matches: {
id: 'matches',
names: ['matches', 'match'],
short: 'a matchbook',
long: 'A damp matchbook with five matches left inside.',
initialState: { uses: 4 },
takeable: true,
lighter: true,
lighterUses: 5,
},
},
}
const s = initialStateFor(matchWorld)
const r = dispatch(s, { kind: 'verb-target', verb: 'examine', target: { canonical: 'matches', raw: 'matches' } }, matchWorld)
expect(r.appended.at(-1)?.text).toBe('A damp matchbook with four matches left inside.')
})
})
describe('dispatcher — inventory', () => {
@@ -369,6 +392,14 @@ describe('light/extinguish verbs (implicit lighter)', () => {
expect(result.appended.at(-1)?.text).toBe("You can't light that.")
})
it('asks what to use a match with instead of lighting the matchbook alone', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'matches', state: { uses: 2 } }] }
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'matches', raw: 'match' } }, world)
expect(result.appended.at(-1)?.text).toBe('Use match with what?')
})
it('emits the lighter-empty message when matches reach 0', () => {
const world = w()
let state = initialStateFor(world)
@@ -459,6 +490,9 @@ describe('use verb routing', () => {
rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } },
items: {
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
matches: { id: 'matches', names: ['matches', 'match'], short: 'a matchbook', long: '.', initialState: { uses: 2 }, takeable: true, lighter: true, lighterUses: 2, lighterEmptyText: 'The book is empty.' },
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.' },
},
encounters: {},
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
@@ -474,6 +508,52 @@ describe('use verb routing', () => {
}, world)
expect(result.appended.at(-1)?.text).toBe("You can't think how to use that here.")
})
it('asks what to use a bare match with', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'matches', state: { uses: 2 } }] }
const result = dispatch(state, {
kind: 'verb-target', verb: 'use', target: { canonical: 'matches', raw: 'match' },
}, world)
expect(result.appended.at(-1)?.text).toBe('Use match with what?')
})
it('burns the letter when using a match with it', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'matches', state: { uses: 2 } }, { id: 'letter', state: {} }] }
const result = dispatch(state, {
kind: 'verb-target-prep', verb: 'use',
target: { canonical: 'matches', raw: 'match' },
preposition: 'with',
indirect: { canonical: 'letter', raw: 'letter' },
}, world)
expect(result.state.inventory.find((i) => i.id === 'letter')).toBeUndefined()
expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(1)
expect(result.state.flags['letterBurned']).toBe(true)
expect(result.appended.at(-1)?.text).toContain('ash')
})
it('lights a lightable item when using a match with it', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [
{ id: 'matches', state: { uses: 2 } },
{ id: 'broken-cigarette', state: { lit: false } },
] }
const result = dispatch(state, {
kind: 'verb-target-prep', verb: 'use',
target: { canonical: 'matches', raw: 'match' },
preposition: 'with',
indirect: { canonical: 'broken-cigarette', raw: 'cigarette' },
}, world)
expect(result.state.inventory.find((i) => i.id === 'broken-cigarette')?.state['lit']).toBe(true)
expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(1)
expect(result.appended.at(-1)?.text).toBe('The end glows once, then steadies. The smoke is bitter.')
})
})
describe('ending detection', () => {
+82 -3
View File
@@ -165,7 +165,13 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
if (command.verb === 'read') return withEndingCheck(handleRead(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'light') return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, null, world), world)
if (command.verb === 'extinguish') return withEndingCheck(handleExtinguish(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'use') return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
if (command.verb === 'use') {
const target = world.items[command.target.canonical]
if (target?.lighter && !target.lightable) {
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: 'Use match 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: `You're not sure how to ${command.verb} that.` }]), world)
}
@@ -180,6 +186,10 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world), world)
}
if (command.verb === 'use') {
const burnResult = handleBurnLetter(stateWithNoun, command.target.canonical, command.indirect.canonical, world)
if (burnResult) return withEndingCheck(burnResult, world)
const lightResult = handleUseAsLight(stateWithNoun, command.target.canonical, command.indirect.canonical, 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: `You're not sure how to ${command.verb} that.` }]), world)
@@ -339,11 +349,25 @@ function handleDrop(state: GameState, itemId: string, _world: World): DispatchRe
function handleExamine(state: GameState, itemId: string, world: World): DispatchResult {
const item = world.items[itemId]
if (!item) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }])
const inventoryInst = state.inventory.find((i) => i.id === itemId) ?? null
const visible =
state.inventory.find((i) => i.id === itemId) ||
inventoryInst ||
getItemsInRoom(state, world, state.location).includes(itemId)
if (!visible) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }])
return narrate(state, [{ kind: 'narration', text: item.long }])
return narrate(state, [{ kind: 'narration', text: describeItem(itemId, item.long, inventoryInst) }])
}
function describeItem(itemId: string, longDescription: string, inst: ItemInstance | null): string {
if (itemId !== 'matches' || typeof inst?.state['uses'] !== 'number') return longDescription
const uses = inst.state['uses']
const noun = uses === 1 ? 'match' : 'matches'
const count = spellSmallCount(uses)
return longDescription.replace(/with \w+ matches? left inside\./i, `with ${count} ${noun} left inside.`)
}
function spellSmallCount(value: number): string {
const words = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten']
return words[value] ?? String(value)
}
function handleRead(state: GameState, itemId: string, world: World): DispatchResult {
@@ -362,6 +386,7 @@ function handleRead(state: GameState, itemId: string, world: World): DispatchRes
function handleLight(state: GameState, targetId: string, instrumentId: string | null, world: World): DispatchResult {
const target = world.items[targetId]
if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
if (target.lighter && !target.lightable) return narrate(state, [{ kind: 'narration', text: 'Use match with what?' }])
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't light that." }])
const targetInst = state.inventory.find((i) => i.id === targetId) ?? null
const visibleInRoom = getItemsInRoom(state, world, state.location).includes(targetId)
@@ -417,6 +442,60 @@ function handleLight(state: GameState, targetId: string, instrumentId: string |
return narrate({ ...state, inventory: newInventory }, lines)
}
function handleBurnLetter(state: GameState, firstId: string, secondId: string, world: World): DispatchResult | null {
const ids = [firstId, secondId]
if (!ids.includes('letter') || !ids.includes('matches')) return null
const matches = state.inventory.find((i) => i.id === 'matches')
if (!matches) return narrate(state, [{ kind: 'narration', text: "You don't have a match." }])
if (typeof matches.state['uses'] === 'number' && matches.state['uses'] <= 0) {
return narrate(state, [{ kind: 'narration', text: 'The matchbook is empty.' }])
}
const letterHeld = state.inventory.some((i) => i.id === 'letter')
const letterInRoom = getItemsInRoom(state, world, state.location).includes('letter')
if (!letterHeld && !letterInRoom) {
return narrate(state, [{ kind: 'narration', text: "You don't see the letter here." }])
}
const newMatchesUses = typeof matches.state['uses'] === 'number' ? matches.state['uses'] - 1 : null
let next: GameState = {
...state,
inventory: state.inventory
.filter((i) => i.id !== 'letter')
.map((i) => i.id === 'matches' && newMatchesUses !== null ? { ...i, state: { ...i.state, uses: newMatchesUses } } : i),
flags: { ...state.flags, letterBurned: true },
}
if (letterInRoom) {
const baseItems = world.rooms[state.location]?.items ?? []
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[] = [
{ kind: 'narration', text: 'The letter catches at one corner. In a few breaths it is ash.' },
]
if (newMatchesUses === 0) {
lines.push({ kind: 'narration', text: world.items['matches']?.lighterEmptyText ?? 'The matchbook is empty.' })
}
return narrate(next, lines)
}
function handleUseAsLight(state: GameState, firstId: string, secondId: string, world: World): DispatchResult | null {
const first = world.items[firstId]
const second = world.items[secondId]
if (first?.lighter && second?.lightable) return handleLight(state, secondId, firstId, world)
if (second?.lighter && first?.lightable) return handleLight(state, firstId, secondId, world)
return null
}
function handleExtinguish(state: GameState, targetId: string, world: World): DispatchResult {
const target = world.items[targetId]
if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
+12 -1
View File
@@ -328,7 +328,7 @@ describe('verb-target-prep with "with"', () => {
knownEncounters: [],
visibleNouns: [
{ id: 'lamp', aliases: ['lamp'] },
{ id: 'matches', aliases: ['matches', 'matchbook'] },
{ id: 'matches', aliases: ['matches', 'match', 'matchbook'] },
],
inventoryItemIds: ['matches'],
lastNoun: null,
@@ -346,6 +346,17 @@ describe('verb-target-prep with "with"', () => {
})
})
it('parses singular "match" aliases', () => {
const cmd = parse('use match with lamp', ctx)
expect(cmd).toEqual({
kind: 'verb-target-prep',
verb: 'use',
target: { canonical: 'matches', raw: 'match' },
preposition: 'with',
indirect: { canonical: 'lamp', raw: 'lamp' },
})
})
it('parses "use shears on vines" into verb-target-prep', () => {
const localCtx: ParserContext = {
knownItems: ['shears', 'ivy-figure'],