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) 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) 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', () => { 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.") 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', () => { it('emits the lighter-empty message when matches reach 0', () => {
const world = w() const world = w()
let state = initialStateFor(world) let state = initialStateFor(world)
@@ -459,6 +490,9 @@ describe('use verb routing', () => {
rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } }, rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } },
items: { items: {
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true }, 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: {}, 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: '' } },
@@ -474,6 +508,52 @@ describe('use verb routing', () => {
}, world) }, world)
expect(result.appended.at(-1)?.text).toBe("You can't think how to use that here.") 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', () => { 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 === '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 === '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 === '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) 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) 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)
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 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) 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 { 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: 'You don\'t see anything like that.' }])
const inventoryInst = state.inventory.find((i) => i.id === itemId) ?? null
const visible = const visible =
state.inventory.find((i) => i.id === itemId) || 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: '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 { 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 { function handleLight(state: GameState, targetId: string, instrumentId: string | null, world: World): DispatchResult {
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: "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." }]) 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 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)
@@ -417,6 +442,60 @@ function handleLight(state: GameState, targetId: string, instrumentId: string |
return narrate({ ...state, inventory: newInventory }, lines) 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 { function handleExtinguish(state: GameState, targetId: string, world: World): DispatchResult {
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: "You don't see anything like that." }])
+12 -1
View File
@@ -328,7 +328,7 @@ describe('verb-target-prep with "with"', () => {
knownEncounters: [], knownEncounters: [],
visibleNouns: [ visibleNouns: [
{ id: 'lamp', aliases: ['lamp'] }, { id: 'lamp', aliases: ['lamp'] },
{ id: 'matches', aliases: ['matches', 'matchbook'] }, { id: 'matches', aliases: ['matches', 'match', 'matchbook'] },
], ],
inventoryItemIds: ['matches'], inventoryItemIds: ['matches'],
lastNoun: null, 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', () => { it('parses "use shears on vines" into verb-target-prep', () => {
const localCtx: ParserContext = { const localCtx: ParserContext = {
knownItems: ['shears', 'ivy-figure'], knownItems: ['shears', 'ivy-figure'],
+2 -1
View File
@@ -39,12 +39,13 @@ describe('computeChips — sample world', () => {
expect(chips.find((c) => c.kind === 'encounter' && c.label === 'ATTACK RAT' && c.command === 'attack rat')).toBeTruthy() expect(chips.find((c) => c.kind === 'encounter' && c.label === 'ATTACK RAT' && c.command === 'attack rat')).toBeTruthy()
}) })
it('always includes LOOK, INV, USE, and HELP', () => { it('always includes LOOK, INV, USE, WAIT, and HELP', () => {
const s = initialStateFor(world) const s = initialStateFor(world)
const chips = computeChips(s, world) const chips = computeChips(s, world)
expect(chips.find((c) => c.command === 'look')).toBeTruthy() expect(chips.find((c) => c.command === 'look')).toBeTruthy()
expect(chips.find((c) => c.command === 'inventory')).toBeTruthy() expect(chips.find((c) => c.command === 'inventory')).toBeTruthy()
expect(chips.find((c) => c.label === 'USE' && c.command === 'use ')).toBeTruthy() expect(chips.find((c) => c.label === 'USE' && c.command === 'use ')).toBeTruthy()
expect(chips.find((c) => c.command === 'wait')).toBeTruthy()
expect(chips.find((c) => c.command === 'help')).toBeTruthy() expect(chips.find((c) => c.command === 'help')).toBeTruthy()
}) })
}) })
+1
View File
@@ -69,6 +69,7 @@ export function computeChips(state: GameState, world: World): Chip[] {
out.push({ kind: 'meta', label: 'LOOK', command: 'look', disabled: false }) out.push({ kind: 'meta', label: 'LOOK', command: 'look', disabled: false })
out.push({ kind: 'meta', label: 'INV', command: 'inventory', disabled: false }) out.push({ kind: 'meta', label: 'INV', command: 'inventory', disabled: false })
out.push({ kind: 'meta', label: 'USE', command: 'use ', disabled: false }) out.push({ kind: 'meta', label: 'USE', command: 'use ', disabled: false })
out.push({ kind: 'meta', label: 'WAIT', command: 'wait', disabled: false })
out.push({ kind: 'meta', label: 'HELP', command: 'help', disabled: false }) out.push({ kind: 'meta', label: 'HELP', command: 'help', disabled: false })
return out return out
+4 -4
View File
@@ -26,10 +26,10 @@
- [x] Feature: / brings focus to terminal - [x] Feature: / brings focus to terminal
- [x] Feature: Add "Restart" option to option menu - [x] Feature: Add "Restart" option to option menu
- [x] Bug: gear icon is still wayyyyyyy toooo smallllll it needs to be like 4x larger at least. - [x] Bug: gear icon is still wayyyyyyy toooo smallllll it needs to be like 4x larger at least.
- [ ] Add a "wait" tile. - [x] Add a "wait" tile.
- [ ] Add a mechanic where after the player waits 3 times or moves six times the light goes out and needs to be relit. - [ ] Add a mechanic where after the player waits 3 times or moves six times the light goes out and needs to be relit.
- [ ] Add attack options for most encounters. Rarely this will be a good idea though. - [ ] Add attack options for most encounters. Rarely this will be a good idea though.
- [ ] Add a failure condition for attacking at the wrong time. Make the reasons for the failure condition contextual, for example, when they attack the stair sleeper they might trip on the stair and get injured. - [ ] Add a failure condition for attacking at the wrong time. Make the reasons for the failure condition contextual, for example, when they attack the stair sleeper they might trip on the stair and get injured.
- [ ] If the user says "light match" or "light match" the response should be "use match with what?" - [x] If the user says "light match" or "light match" the response should be "use match with what?"
- [ ] If the user says "use match with letter" they should burn the letter. - [x] If the user says "use match with letter" they should burn the letter.
- [ ] There should be a lighter in the smoking room that allows unlimited lighting. - [x] There should be a lighter in the smoking room that allows unlimited lighting.
+10 -5
View File
@@ -1,15 +1,20 @@
--- ---
id: matches id: matches
names: ["matches", "safety matches", "matchbook", "box"] names:
short: "a matchbook" - matches
- match
- safety matches
- matchbook
- box
short: a matchbook
takeable: true takeable: true
lighter: true lighter: true
lighterUses: 4 lighterUses: 5
initialState: initialState:
uses: 4 uses: 5
--- ---
A damp matchbook with four matches left inside. A damp matchbook with five matches left inside.
## lighter-empty ## lighter-empty
The last match flares, burns down, and goes out. The book is empty. The last match flares, burns down, and goes out. The book is empty.