This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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." }])
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user