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'],
+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()
})
it('always includes LOOK, INV, USE, and HELP', () => {
it('always includes LOOK, INV, USE, WAIT, and HELP', () => {
const s = initialStateFor(world)
const chips = computeChips(s, world)
expect(chips.find((c) => c.command === 'look')).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.command === 'wait')).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: 'INV', command: 'inventory', 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 })
return out
+4 -4
View File
@@ -26,10 +26,10 @@
- [x] Feature: / brings focus to terminal
- [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.
- [ ] 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 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.
- [ ] 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.
- [ ] There should be a lighter in the smoking room that allows unlimited lighting.
- [x] If the user says "light match" or "light match" the response should be "use match with what?"
- [x] If the user says "use match with letter" they should burn the letter.
- [x] There should be a lighter in the smoking room that allows unlimited lighting.
+10 -5
View File
@@ -1,15 +1,20 @@
---
id: matches
names: ["matches", "safety matches", "matchbook", "box"]
short: "a matchbook"
names:
- matches
- match
- safety matches
- matchbook
- box
short: a matchbook
takeable: true
lighter: true
lighterUses: 4
lighterUses: 5
initialState:
uses: 4
uses: 5
---
A damp matchbook with four matches left inside.
A damp matchbook with five matches left inside.
## lighter-empty
The last match flares, burns down, and goes out. The book is empty.