Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f8e3b1a34 | |||
| e167979fa7 | |||
| 19d1efc586 | |||
| 0d9db9bb55 | |||
| b870d884ef | |||
| 8401e7d281 | |||
| dac8487dbe | |||
| 2fecc7878d | |||
| ee3cfcc00d | |||
| df50afa479 |
@@ -94,13 +94,60 @@ describe('dispatcher — go', () => {
|
|||||||
expect(r.state.location).toBe('hallway')
|
expect(r.state.location).toBe('hallway')
|
||||||
expect(r.appended.some((l) => l.text.includes('locked'))).toBe(true)
|
expect(r.appended.some((l) => l.text.includes('locked'))).toBe(true)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('opens a locked exit when required item is in inventory', () => {
|
describe('locked exits', () => {
|
||||||
// Locked-exit-with-key happy path is covered by the playthrough integration
|
function makeWorld(): World {
|
||||||
// test in Task 8. The sample world above doesn't have an unlocked path to
|
return {
|
||||||
// pick up the brass key without first traversing the locked door, so this
|
startingRoom: 'antechamber',
|
||||||
// test is intentionally a placeholder.
|
startingInventory: [],
|
||||||
expect(true).toBe(true)
|
rooms: {
|
||||||
|
antechamber: {
|
||||||
|
id: 'antechamber',
|
||||||
|
title: '[ Antechamber ]',
|
||||||
|
descriptions: { firstVisit: '.', revisit: '.', examined: '.' },
|
||||||
|
exits: { n: 'vault' },
|
||||||
|
lockedExits: { n: { requires: 'rusted-key', lockedNarration: 'The door is locked.' } },
|
||||||
|
items: ['rusted-key'],
|
||||||
|
},
|
||||||
|
vault: {
|
||||||
|
id: 'vault',
|
||||||
|
title: '[ Vault ]',
|
||||||
|
descriptions: { firstVisit: 'You are inside.', revisit: '.', examined: '.' },
|
||||||
|
exits: {},
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
'rusted-key': { id: 'rusted-key', names: ['rusted key', 'key'], short: 'a rusted key', long: '.', initialState: {}, takeable: true },
|
||||||
|
},
|
||||||
|
encounters: {},
|
||||||
|
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('blocks movement without the key', () => {
|
||||||
|
const world = makeWorld()
|
||||||
|
const state = initialStateFor(world)
|
||||||
|
const result = dispatch(state, { kind: 'go', direction: 'n' }, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe('The door is locked.')
|
||||||
|
expect(result.state.location).toBe('antechamber')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('permits movement once the key is in inventory', () => {
|
||||||
|
const world = makeWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'rusted-key', state: {} }] }
|
||||||
|
const result = dispatch(state, { kind: 'go', direction: 'n' }, world)
|
||||||
|
expect(result.state.location).toBe('vault')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not consume the key on passage', () => {
|
||||||
|
const world = makeWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'rusted-key', state: {} }] }
|
||||||
|
const result = dispatch(state, { kind: 'go', direction: 'n' }, world)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'rusted-key')).toBeDefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -178,9 +225,9 @@ describe('ambiguous → disambiguation flow', () => {
|
|||||||
},
|
},
|
||||||
encounters: {},
|
encounters: {},
|
||||||
endings: {
|
endings: {
|
||||||
true: { whenFlags: {}, narration: '' },
|
true: { whenFlags: { _never: true }, narration: '' },
|
||||||
wrong: { whenFlags: {}, narration: '' },
|
wrong: { whenFlags: { _never: true }, narration: '' },
|
||||||
bad: { whenFlags: {}, narration: '' },
|
bad: { whenFlags: { _never: true }, narration: '' },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,3 +259,274 @@ describe('ambiguous → disambiguation flow', () => {
|
|||||||
expect(result.state.inventory.find((i) => i.id === 'iron-key')).toBeDefined()
|
expect(result.state.inventory.find((i) => i.id === 'iron-key')).toBeDefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function readWorld(): World {
|
||||||
|
return {
|
||||||
|
startingRoom: 'r',
|
||||||
|
startingInventory: [],
|
||||||
|
rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } },
|
||||||
|
items: {
|
||||||
|
letter: { id: 'letter', names: ['letter'], short: 'a letter', long: 'A letter.', initialState: {}, takeable: true, readable: true, readableText: 'You loved Halfstreet, the letter says.' },
|
||||||
|
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: 'A rock.', initialState: {}, takeable: true },
|
||||||
|
},
|
||||||
|
encounters: {},
|
||||||
|
endings: {
|
||||||
|
true: { whenFlags: { _never: true }, narration: '' },
|
||||||
|
wrong: { whenFlags: { _never: true }, narration: '' },
|
||||||
|
bad: { whenFlags: { _never: true }, narration: '' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('read verb', () => {
|
||||||
|
it('narrates readableText for a readable item in inventory', () => {
|
||||||
|
const world = readWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'letter', state: {} }] }
|
||||||
|
const result = dispatch(state, {
|
||||||
|
kind: 'verb-target', verb: 'read', target: { canonical: 'letter', raw: 'letter' },
|
||||||
|
}, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe('You loved Halfstreet, the letter says.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('errors politely on non-readable items', () => {
|
||||||
|
const world = readWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'rock', state: {} }] }
|
||||||
|
const result = dispatch(state, {
|
||||||
|
kind: 'verb-target', verb: 'read', target: { canonical: 'rock', raw: 'rock' },
|
||||||
|
}, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe("There's nothing to read on it.")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('light/extinguish verbs (implicit lighter)', () => {
|
||||||
|
function w(): World {
|
||||||
|
return {
|
||||||
|
startingRoom: 'r',
|
||||||
|
startingInventory: [],
|
||||||
|
rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } },
|
||||||
|
items: {
|
||||||
|
lamp: { id: 'lamp', names: ['lamp'], short: 'an oil lamp', long: '.', initialState: { lit: false }, takeable: true, lightable: true, litText: 'The wick catches.', extinguishedText: 'The flame dies.' },
|
||||||
|
matches: { id: 'matches', names: ['matches'], short: 'a matchbook', long: '.', initialState: {}, takeable: true, lighter: true, lighterUses: 2, lighterEmptyText: 'The book is empty.' },
|
||||||
|
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
|
||||||
|
},
|
||||||
|
encounters: {},
|
||||||
|
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lights a lamp using the matchbook implicitly', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [
|
||||||
|
{ id: 'lamp', state: { lit: false } },
|
||||||
|
{ id: 'matches', state: { uses: 2 } },
|
||||||
|
] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'lamp')?.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 wick catches.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses when the target is already lit', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [
|
||||||
|
{ id: 'lamp', state: { lit: true } },
|
||||||
|
{ id: 'matches', state: { uses: 2 } },
|
||||||
|
] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe("It's already lit.")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses when no lighter is in inventory', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'lamp', state: { lit: false } }] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe('You have nothing to light it with.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses when the target is not lightable', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'rock', state: {} }, { id: 'matches', state: { uses: 2 } }] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'rock', raw: 'rock' } }, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe("You can't light that.")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits the lighter-empty message when matches reach 0', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [
|
||||||
|
{ id: 'lamp', state: { lit: false } },
|
||||||
|
{ id: 'matches', state: { uses: 1 } },
|
||||||
|
] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(0)
|
||||||
|
const texts = result.appended.map((l) => l.text)
|
||||||
|
expect(texts).toContain('The wick catches.')
|
||||||
|
expect(texts).toContain('The book is empty.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extinguishes a lit lamp', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'lamp', state: { lit: true } }] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'extinguish', target: { canonical: 'lamp', raw: 'lamp' } }, world)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(false)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe('The flame dies.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("refuses to extinguish what isn't lit", () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'lamp', state: { lit: false } }] }
|
||||||
|
const result = dispatch(state, { kind: 'verb-target', verb: 'extinguish', target: { canonical: 'lamp', raw: 'lamp' } }, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe("It isn't lit.")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('light X with Y (explicit lighter)', () => {
|
||||||
|
function w(): World {
|
||||||
|
return {
|
||||||
|
startingRoom: 'r',
|
||||||
|
startingInventory: [],
|
||||||
|
rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } },
|
||||||
|
items: {
|
||||||
|
lamp: { id: 'lamp', names: ['lamp'], short: 'an oil lamp', long: '.', initialState: { lit: false }, takeable: true, lightable: true, litText: 'The wick catches.', extinguishedText: 'The flame dies.' },
|
||||||
|
matches: { id: 'matches', names: ['matches'], short: 'a matchbook', long: '.', initialState: {}, takeable: true, lighter: true, lighterUses: 2, lighterEmptyText: 'The book is empty.' },
|
||||||
|
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
|
||||||
|
},
|
||||||
|
encounters: {},
|
||||||
|
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lights with the explicit instrument', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [
|
||||||
|
{ id: 'lamp', state: { lit: false } },
|
||||||
|
{ id: 'matches', state: { uses: 2 } },
|
||||||
|
] }
|
||||||
|
const result = dispatch(state, {
|
||||||
|
kind: 'verb-target-prep', verb: 'light',
|
||||||
|
target: { canonical: 'lamp', raw: 'lamp' },
|
||||||
|
preposition: 'with',
|
||||||
|
indirect: { canonical: 'matches', raw: 'matches' },
|
||||||
|
}, world)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses when the named instrument is not a lighter', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [
|
||||||
|
{ id: 'lamp', state: { lit: false } },
|
||||||
|
{ id: 'rock', state: {} },
|
||||||
|
] }
|
||||||
|
const result = dispatch(state, {
|
||||||
|
kind: 'verb-target-prep', verb: 'light',
|
||||||
|
target: { canonical: 'lamp', raw: 'lamp' },
|
||||||
|
preposition: 'with',
|
||||||
|
indirect: { canonical: 'rock', raw: 'rock' },
|
||||||
|
}, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe("That isn't going to help.")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('use verb routing', () => {
|
||||||
|
function w(): World {
|
||||||
|
return {
|
||||||
|
startingRoom: 'r',
|
||||||
|
startingInventory: [],
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
encounters: {},
|
||||||
|
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('falls back when no encounter consumes use', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'rock', state: {} }] }
|
||||||
|
const result = dispatch(state, {
|
||||||
|
kind: 'verb-target', verb: 'use', target: { canonical: 'rock', raw: 'rock' },
|
||||||
|
}, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe("You can't think how to use that here.")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ending detection', () => {
|
||||||
|
function makeWorld(): World {
|
||||||
|
return {
|
||||||
|
startingRoom: 'r',
|
||||||
|
startingInventory: [],
|
||||||
|
rooms: {
|
||||||
|
r: {
|
||||||
|
id: 'r',
|
||||||
|
title: '[ R ]',
|
||||||
|
descriptions: { firstVisit: '.', revisit: '.', examined: '.' },
|
||||||
|
exits: { n: 'r2' },
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
r2: {
|
||||||
|
id: 'r2',
|
||||||
|
title: '[ R2 ]',
|
||||||
|
descriptions: { firstVisit: '.', revisit: '.', examined: '.' },
|
||||||
|
exits: {},
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: {},
|
||||||
|
encounters: {},
|
||||||
|
endings: {
|
||||||
|
true: { whenFlags: { reachedR2: true }, narration: 'You stand at the top of the stair.' },
|
||||||
|
wrong: { whenFlags: {}, narration: 'You disturb what should not be disturbed.' },
|
||||||
|
bad: { whenFlags: { tookPhoto: true }, narration: 'The child in it is you.' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sets endedWith and emits an ending line when flags match', () => {
|
||||||
|
const world = makeWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, flags: { reachedR2: true } }
|
||||||
|
const result = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world)
|
||||||
|
expect(result.state.endedWith).toBe('true')
|
||||||
|
const last = result.appended.at(-1)!
|
||||||
|
expect(last.kind).toBe('ending')
|
||||||
|
expect(last.text).toBe('You stand at the top of the stair.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honors priority order: true beats wrong beats bad', () => {
|
||||||
|
const world = makeWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, flags: { reachedR2: true } }
|
||||||
|
const result = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world)
|
||||||
|
expect(result.state.endedWith).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects further input once ended', () => {
|
||||||
|
const world = makeWorld()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, flags: { reachedR2: true } }
|
||||||
|
const ended = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world).state
|
||||||
|
const result = dispatch(ended, { kind: 'verb-only', verb: 'wait' }, world)
|
||||||
|
expect(result.appended.at(-1)?.text).toBe('The story has ended. Type `restart` or `undo`.')
|
||||||
|
expect(result.state.location).toBe(ended.location)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire on unknown turns (no state mutation)', () => {
|
||||||
|
const world = makeWorld()
|
||||||
|
const state = initialStateFor(world)
|
||||||
|
const result = dispatch(state, { kind: 'unknown', raw: 'fnord', reason: 'unknown-verb' }, world)
|
||||||
|
expect(result.state.endedWith).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+148
-9
@@ -55,6 +55,34 @@ function setRoomFlag(state: GameState, roomId: string, key: string, value: strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ENDING_PRIORITY: ('true' | 'wrong' | 'bad')[] = ['true', 'wrong', 'bad']
|
||||||
|
|
||||||
|
function evaluateEndings(state: GameState, world: World): GameState | null {
|
||||||
|
if (state.endedWith) return null
|
||||||
|
for (const id of ENDING_PRIORITY) {
|
||||||
|
const ending = world.endings[id]
|
||||||
|
const flags = ending.whenFlags
|
||||||
|
let allMatch = true
|
||||||
|
for (const [k, v] of Object.entries(flags)) {
|
||||||
|
if (state.flags[k] !== v) { allMatch = false; break }
|
||||||
|
}
|
||||||
|
if (!allMatch) continue
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
endedWith: id,
|
||||||
|
transcript: [...state.transcript, { kind: 'ending', text: ending.narration }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function withEndingCheck(result: DispatchResult, world: World): DispatchResult {
|
||||||
|
const updated = evaluateEndings(result.state, world)
|
||||||
|
if (!updated) return result
|
||||||
|
const endingLine: TranscriptLine = updated.transcript[updated.transcript.length - 1]!
|
||||||
|
return { state: updated, appended: [...result.appended, endingLine] }
|
||||||
|
}
|
||||||
|
|
||||||
export function dispatch(state: GameState, command: ParsedCommand, world: World): DispatchResult {
|
export function dispatch(state: GameState, command: ParsedCommand, world: World): DispatchResult {
|
||||||
// Disambiguation reply: re-issue the original verb with the chosen target.
|
// Disambiguation reply: re-issue the original verb with the chosen target.
|
||||||
if (command.kind === 'disambiguation') {
|
if (command.kind === 'disambiguation') {
|
||||||
@@ -70,6 +98,11 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Once the game has ended, only restart/undo (handled by the UI) can clear state.
|
||||||
|
if (state.endedWith) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: 'The story has ended. Type `restart` or `undo`.' }])
|
||||||
|
}
|
||||||
|
|
||||||
if (command.kind === 'unknown') {
|
if (command.kind === 'unknown') {
|
||||||
const text =
|
const text =
|
||||||
command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.'
|
command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.'
|
||||||
@@ -83,7 +116,7 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command.kind === 'go') {
|
if (command.kind === 'go') {
|
||||||
return handleGo(state, command.direction, world)
|
return withEndingCheck(handleGo(state, command.direction, world), world)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.kind === 'ambiguous') {
|
if (command.kind === 'ambiguous') {
|
||||||
@@ -101,9 +134,9 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command.kind === 'verb-only') {
|
if (command.kind === 'verb-only') {
|
||||||
if (command.verb === 'look') return handleLook(state, world)
|
if (command.verb === 'look') return withEndingCheck(handleLook(state, world), world)
|
||||||
if (command.verb === 'inventory') return handleInventory(state, world)
|
if (command.verb === 'inventory') return withEndingCheck(handleInventory(state, world), world)
|
||||||
if (command.verb === 'wait') return narrate(state, [{ kind: 'narration', text: 'Time passes.' }])
|
if (command.verb === 'wait') return withEndingCheck(narrate(state, [{ kind: 'narration', text: 'Time passes.' }]), world)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.kind === 'verb-target') {
|
if (command.kind === 'verb-target') {
|
||||||
@@ -111,12 +144,32 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
|
|||||||
// Try the active encounter first — it may consume verbs like 'attack', 'hold'.
|
// Try the active encounter first — it may consume verbs like 'attack', 'hold'.
|
||||||
const encResult = applyVerbToEncounter(stateWithNoun, command, world)
|
const encResult = applyVerbToEncounter(stateWithNoun, command, world)
|
||||||
if (encResult?.consumed) {
|
if (encResult?.consumed) {
|
||||||
return { state: encResult.state, appended: encResult.lines }
|
return withEndingCheck({ state: encResult.state, appended: encResult.lines }, world)
|
||||||
}
|
}
|
||||||
if (command.verb === 'take') return handleTake(stateWithNoun, command.target.canonical, world)
|
if (command.verb === 'take') return withEndingCheck(handleTake(stateWithNoun, command.target.canonical, world), world)
|
||||||
if (command.verb === 'drop') return handleDrop(stateWithNoun, command.target.canonical, world)
|
if (command.verb === 'drop') return withEndingCheck(handleDrop(stateWithNoun, command.target.canonical, world), world)
|
||||||
if (command.verb === 'examine' || command.verb === 'look') return handleExamine(stateWithNoun, command.target.canonical, world)
|
if (command.verb === 'examine' || command.verb === 'look') return withEndingCheck(handleExamine(stateWithNoun, command.target.canonical, world), world)
|
||||||
return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }])
|
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)
|
||||||
|
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command.kind === 'verb-target-prep') {
|
||||||
|
const stateWithNoun: GameState = { ...state, lastNoun: command.target }
|
||||||
|
// Try the encounter first — it may consume verbs like 'cut vines with shears'.
|
||||||
|
const encResult = applyVerbToEncounter(stateWithNoun, command, world)
|
||||||
|
if (encResult?.consumed) {
|
||||||
|
return withEndingCheck({ state: encResult.state, appended: encResult.lines }, world)
|
||||||
|
}
|
||||||
|
if (command.verb === 'light' && command.preposition === 'with') {
|
||||||
|
return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world), world)
|
||||||
|
}
|
||||||
|
if (command.verb === 'use') {
|
||||||
|
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 narrate(state, [{ kind: 'narration', text: 'Nothing happens.' }])
|
return narrate(state, [{ kind: 'narration', text: 'Nothing happens.' }])
|
||||||
@@ -262,3 +315,89 @@ function handleExamine(state: GameState, itemId: string, world: World): Dispatch
|
|||||||
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: item.long }])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRead(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 visible =
|
||||||
|
state.inventory.find((i) => i.id === itemId) ||
|
||||||
|
getItemsInRoom(state, world, state.location).includes(itemId)
|
||||||
|
if (!visible) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
|
||||||
|
if (!item.readable || !item.readableText) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: "There's nothing to read on it." }])
|
||||||
|
}
|
||||||
|
return narrate(state, [{ kind: 'narration', text: item.readableText }])
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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)
|
||||||
|
if (!targetInst && !visibleInRoom) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
|
||||||
|
}
|
||||||
|
// The 'lit' state lives on the inventory instance for inventory items, or
|
||||||
|
// (eventually) on roomState for items left in a room. For now we only
|
||||||
|
// support lighting items the player is carrying.
|
||||||
|
if (!targetInst) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }])
|
||||||
|
}
|
||||||
|
if (targetInst.state['lit'] === true) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: "It's already lit." }])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick an instrument. If explicit, validate it; if implicit, find any.
|
||||||
|
let lighterInst = null as typeof state.inventory[number] | null
|
||||||
|
if (instrumentId) {
|
||||||
|
lighterInst = state.inventory.find((i) => i.id === instrumentId) ?? null
|
||||||
|
if (!lighterInst) return narrate(state, [{ kind: 'narration', text: "You don't have that." }])
|
||||||
|
const lighterDef = world.items[instrumentId]
|
||||||
|
if (!lighterDef?.lighter) return narrate(state, [{ kind: 'narration', text: "That isn't going to help." }])
|
||||||
|
if (typeof lighterInst.state['uses'] === 'number' && lighterInst.state['uses'] <= 0) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: "It is spent." }])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const inst of state.inventory) {
|
||||||
|
const def = world.items[inst.id]
|
||||||
|
if (!def?.lighter) continue
|
||||||
|
if (typeof inst.state['uses'] === 'number' && inst.state['uses'] <= 0) continue
|
||||||
|
lighterInst = inst
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (!lighterInst) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: 'You have nothing to light it with.' }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply state changes immutably.
|
||||||
|
const lighterDef = world.items[lighterInst.id]!
|
||||||
|
const lighterUsesField = typeof lighterInst.state['uses'] === 'number' ? lighterInst.state['uses'] : null
|
||||||
|
const newLighterUses = lighterUsesField === null ? null : lighterUsesField - 1
|
||||||
|
const newInventory = state.inventory.map((i) => {
|
||||||
|
if (i.id === targetInst.id) return { ...i, state: { ...i.state, lit: true } }
|
||||||
|
if (i.id === lighterInst!.id && newLighterUses !== null) return { ...i, state: { ...i.state, uses: newLighterUses } }
|
||||||
|
return i
|
||||||
|
})
|
||||||
|
const lines: TranscriptLine[] = [{ kind: 'narration', text: target.litText ?? 'It catches.' }]
|
||||||
|
if (newLighterUses === 0) {
|
||||||
|
lines.push({ kind: 'narration', text: lighterDef.lighterEmptyText ?? 'It is spent.' })
|
||||||
|
}
|
||||||
|
return narrate({ ...state, inventory: newInventory }, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
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." }])
|
||||||
|
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't extinguish that." }])
|
||||||
|
const targetInst = state.inventory.find((i) => i.id === targetId)
|
||||||
|
if (!targetInst) return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }])
|
||||||
|
if (targetInst.state['lit'] !== true) {
|
||||||
|
return narrate(state, [{ kind: 'narration', text: "It isn't lit." }])
|
||||||
|
}
|
||||||
|
const newInventory = state.inventory.map((i) =>
|
||||||
|
i.id === targetId ? { ...i, state: { ...i.state, lit: false } } : i,
|
||||||
|
)
|
||||||
|
return narrate({ ...state, inventory: newInventory }, [{ kind: 'narration', text: target.extinguishedText ?? 'The flame dies.' }])
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ const world: World = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
endings: {
|
endings: {
|
||||||
true: { whenFlags: {}, narration: '' },
|
true: { whenFlags: { _never: true }, narration: '' },
|
||||||
wrong: { whenFlags: {}, narration: '' },
|
wrong: { whenFlags: { _never: true }, narration: '' },
|
||||||
bad: { whenFlags: {}, narration: '' },
|
bad: { whenFlags: { _never: true }, narration: '' },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,12 +66,17 @@ export function applyVerbToEncounter(
|
|||||||
const phaseDef = def.phases[currentPhase]
|
const phaseDef = def.phases[currentPhase]
|
||||||
if (!phaseDef) return null
|
if (!phaseDef) return null
|
||||||
|
|
||||||
// Only verb-target and verb-only commands engage with encounters.
|
// Only verb-target, verb-target-prep, and verb-only commands engage with encounters.
|
||||||
let verb: string | null = null
|
let verb: string | null = null
|
||||||
let targetId: string | null = null
|
let targetId: string | null = null
|
||||||
|
let instrumentId: string | null = null
|
||||||
if (command.kind === 'verb-target') {
|
if (command.kind === 'verb-target') {
|
||||||
verb = command.verb
|
verb = command.verb
|
||||||
targetId = command.target.canonical
|
targetId = command.target.canonical
|
||||||
|
} else if (command.kind === 'verb-target-prep') {
|
||||||
|
verb = command.verb
|
||||||
|
targetId = command.target.canonical
|
||||||
|
instrumentId = command.indirect.canonical
|
||||||
} else if (command.kind === 'verb-only' && command.verb !== 'inventory') {
|
} else if (command.kind === 'verb-only' && command.verb !== 'inventory') {
|
||||||
verb = command.verb
|
verb = command.verb
|
||||||
} else {
|
} else {
|
||||||
@@ -91,6 +96,7 @@ export function applyVerbToEncounter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (t.requires && instrumentId && t.requires.item !== instrumentId) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -169,3 +169,16 @@
|
|||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
.mystery-chips { display: flex; }
|
.mystery-chips { display: flex; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mystery-transcript .ending {
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
padding-top: 1em;
|
||||||
|
border-top: 1px solid currentColor;
|
||||||
|
font-style: italic;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mystery-input].ended {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ if (!transcriptEl || !inputEl) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncEndedUI = (): void => {
|
||||||
|
// Don't disable the input — the player still needs to type `restart` or
|
||||||
|
// `undo`. A `disabled` input rejects keydown events entirely. Use a class
|
||||||
|
// for visual styling instead; the keydown handler enforces the input
|
||||||
|
// restriction.
|
||||||
|
inputEl!.classList.toggle('ended', state.endedWith !== null)
|
||||||
|
}
|
||||||
|
|
||||||
const buildParserContext = (s: GameState): ParserContext => {
|
const buildParserContext = (s: GameState): ParserContext => {
|
||||||
const room = world.rooms[s.location]
|
const room = world.rooms[s.location]
|
||||||
const visibleNouns: { id: string; aliases: string[] }[] = []
|
const visibleNouns: { id: string; aliases: string[] }[] = []
|
||||||
@@ -81,6 +89,7 @@ if (!transcriptEl || !inputEl) {
|
|||||||
|
|
||||||
renderAll(state.transcript)
|
renderAll(state.transcript)
|
||||||
refreshChips()
|
refreshChips()
|
||||||
|
syncEndedUI()
|
||||||
inputEl.focus()
|
inputEl.focus()
|
||||||
|
|
||||||
inputEl.addEventListener('keydown', (e) => {
|
inputEl.addEventListener('keydown', (e) => {
|
||||||
@@ -91,6 +100,15 @@ if (!transcriptEl || !inputEl) {
|
|||||||
if (!raw.trim()) return
|
if (!raw.trim()) return
|
||||||
appendLines([{ kind: 'player', text: raw }])
|
appendLines([{ kind: 'player', text: raw }])
|
||||||
|
|
||||||
|
// Once the game has ended, only restart and undo are allowed.
|
||||||
|
if (state.endedWith !== null) {
|
||||||
|
const lower = raw.trim().toLowerCase()
|
||||||
|
if (lower !== 'restart' && lower !== 'undo') {
|
||||||
|
appendLines([{ kind: 'system', text: 'The story has ended. Type `restart` or `undo`.' }])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Engine-level meta-commands handled here so the engine stays pure.
|
// Engine-level meta-commands handled here so the engine stays pure.
|
||||||
const trimmed = raw.trim().toLowerCase()
|
const trimmed = raw.trim().toLowerCase()
|
||||||
if (trimmed === 'restart') {
|
if (trimmed === 'restart') {
|
||||||
@@ -105,6 +123,7 @@ if (!transcriptEl || !inputEl) {
|
|||||||
renderAll(state.transcript)
|
renderAll(state.transcript)
|
||||||
saveState(state)
|
saveState(state)
|
||||||
refreshChips()
|
refreshChips()
|
||||||
|
syncEndedUI()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (trimmed === 'undo') {
|
if (trimmed === 'undo') {
|
||||||
@@ -114,6 +133,7 @@ if (!transcriptEl || !inputEl) {
|
|||||||
appendLines([{ kind: 'system', text: '(undone)' }])
|
appendLines([{ kind: 'system', text: '(undone)' }])
|
||||||
saveState(state)
|
saveState(state)
|
||||||
refreshChips()
|
refreshChips()
|
||||||
|
syncEndedUI()
|
||||||
} else {
|
} else {
|
||||||
appendLines([{ kind: 'system', text: 'There is no further back.' }])
|
appendLines([{ kind: 'system', text: 'There is no further back.' }])
|
||||||
}
|
}
|
||||||
@@ -139,6 +159,7 @@ if (!transcriptEl || !inputEl) {
|
|||||||
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
|
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
|
||||||
}
|
}
|
||||||
refreshChips()
|
refreshChips()
|
||||||
|
syncEndedUI()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[halfstreet] dispatch error', err)
|
console.error('[halfstreet] dispatch error', err)
|
||||||
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }])
|
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }])
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
id: bad
|
id: bad
|
||||||
whenFlags: {}
|
whenFlags:
|
||||||
|
_never: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
id: wrong
|
id: wrong
|
||||||
whenFlags: {}
|
whenFlags:
|
||||||
|
_never: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ id: lamp
|
|||||||
names: ["lamp", "oil lamp", "torch"]
|
names: ["lamp", "oil lamp", "torch"]
|
||||||
short: "an oil lamp"
|
short: "an oil lamp"
|
||||||
takeable: true
|
takeable: true
|
||||||
|
lightable: true
|
||||||
initialState:
|
initialState:
|
||||||
lit: false
|
lit: false
|
||||||
---
|
---
|
||||||
|
|
||||||
An iron oil lamp with a glass chimney. Currently unlit.
|
An iron oil lamp with a glass chimney. Currently unlit.
|
||||||
|
|
||||||
|
## lit
|
||||||
|
The wick catches. Warm yellow light pushes the dark back.
|
||||||
|
|
||||||
|
## extinguished
|
||||||
|
You smother the wick. The room closes around you again.
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ id: letter
|
|||||||
names: ["letter", "folded letter", "paper"]
|
names: ["letter", "folded letter", "paper"]
|
||||||
short: "a folded letter"
|
short: "a folded letter"
|
||||||
takeable: true
|
takeable: true
|
||||||
|
readable: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
A folded letter. The wax seal has been broken once already.
|
||||||
|
|
||||||
|
## read
|
||||||
A folded letter on yellowed paper. The hand is unfamiliar. It reads: "Come at once. The thing in the cellar is waking."
|
A folded letter on yellowed paper. The hand is unfamiliar. It reads: "Come at once. The thing in the cellar is waking."
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ id: matches
|
|||||||
names: ["matches", "safety matches", "box"]
|
names: ["matches", "safety matches", "box"]
|
||||||
short: "a box of safety matches"
|
short: "a box of safety matches"
|
||||||
takeable: true
|
takeable: true
|
||||||
|
lighter: true
|
||||||
|
lighterUses: 4
|
||||||
|
initialState:
|
||||||
|
uses: 4
|
||||||
---
|
---
|
||||||
|
|
||||||
A small cardboard box of safety matches. Half-full.
|
A small cardboard box of safety matches. Half-full.
|
||||||
|
|
||||||
|
## lighter-empty
|
||||||
|
The last match flares, burns down, and goes out. The book is empty.
|
||||||
|
|||||||
@@ -311,6 +311,118 @@ describe('narration registry', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('parseItem — body sections', () => {
|
||||||
|
it('extracts ## read into readableText', () => {
|
||||||
|
const md = `---
|
||||||
|
id: letter
|
||||||
|
names: [letter, note]
|
||||||
|
short: a folded letter
|
||||||
|
takeable: true
|
||||||
|
readable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
A folded letter, sealed with wax.
|
||||||
|
|
||||||
|
## read
|
||||||
|
You loved Halfstreet, the letter says. I loved it too.
|
||||||
|
`
|
||||||
|
const item = parseItem(md, 'items/letter.md')
|
||||||
|
expect(item.long).toBe('A folded letter, sealed with wax.')
|
||||||
|
expect(item.readable).toBe(true)
|
||||||
|
expect(item.readableText).toBe('You loved Halfstreet, the letter says. I loved it too.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts ## lit and ## extinguished', () => {
|
||||||
|
const md = `---
|
||||||
|
id: lamp
|
||||||
|
names: [lamp]
|
||||||
|
short: an oil lamp
|
||||||
|
takeable: true
|
||||||
|
lightable: true
|
||||||
|
initialState:
|
||||||
|
lit: false
|
||||||
|
---
|
||||||
|
|
||||||
|
An iron oil lamp.
|
||||||
|
|
||||||
|
## lit
|
||||||
|
The wick catches; warm yellow light fills the space.
|
||||||
|
|
||||||
|
## extinguished
|
||||||
|
You smother the flame. The room darkens.
|
||||||
|
`
|
||||||
|
const item = parseItem(md, 'items/lamp.md')
|
||||||
|
expect(item.long).toBe('An iron oil lamp.')
|
||||||
|
expect(item.litText).toBe('The wick catches; warm yellow light fills the space.')
|
||||||
|
expect(item.extinguishedText).toBe('You smother the flame. The room darkens.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts ## lighter-empty', () => {
|
||||||
|
const md = `---
|
||||||
|
id: matches
|
||||||
|
names: [matches]
|
||||||
|
short: a matchbook
|
||||||
|
takeable: true
|
||||||
|
lighter: true
|
||||||
|
lighterUses: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
A matchbook from the Halfstreet Hotel.
|
||||||
|
|
||||||
|
## lighter-empty
|
||||||
|
The last match flares and dies. The book is empty.
|
||||||
|
`
|
||||||
|
const item = parseItem(md, 'items/matches.md')
|
||||||
|
expect(item.lighterEmptyText).toBe('The last match flares and dies. The book is empty.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when readable: true but ## read is missing', () => {
|
||||||
|
const md = `---
|
||||||
|
id: x
|
||||||
|
names: [x]
|
||||||
|
short: x
|
||||||
|
takeable: true
|
||||||
|
readable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
A thing.
|
||||||
|
`
|
||||||
|
expect(() => parseItem(md, 'items/x.md')).toThrow(/## read.*required when readable/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still parses items with no body sections (back-compat)', () => {
|
||||||
|
const md = `---
|
||||||
|
id: lamp
|
||||||
|
names: [lamp]
|
||||||
|
short: an oil lamp
|
||||||
|
takeable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
An iron oil lamp with a glass chimney.
|
||||||
|
`
|
||||||
|
const item = parseItem(md, 'items/lamp.md')
|
||||||
|
expect(item.long).toBe('An iron oil lamp with a glass chimney.')
|
||||||
|
expect(item.readable).toBeUndefined()
|
||||||
|
expect(item.readableText).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws for unknown section keys', () => {
|
||||||
|
const md = `---
|
||||||
|
id: x
|
||||||
|
names: [x]
|
||||||
|
short: x
|
||||||
|
takeable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
A thing.
|
||||||
|
|
||||||
|
## badkey
|
||||||
|
Content.
|
||||||
|
`
|
||||||
|
expect(() => parseItem(md, 'items/x.md')).toThrow(/unknown item section "## badkey".*Allowed:.*read.*lit.*extinguished.*lighter-empty/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('parseRoom invalid headers', () => {
|
describe('parseRoom invalid headers', () => {
|
||||||
it('throws a clear error when a header has spaces', () => {
|
it('throws a clear error when a header has spaces', () => {
|
||||||
const md = `---
|
const md = `---
|
||||||
|
|||||||
+35
-5
@@ -130,27 +130,57 @@ export function parseRoom(raw: string, sourcePath: string): Room {
|
|||||||
return room
|
return room
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ITEM_SECTION_KEYS = ['read', 'lit', 'extinguished', 'lighter-empty'] as const
|
||||||
|
type ItemSectionKey = typeof ITEM_SECTION_KEYS[number]
|
||||||
|
|
||||||
export function parseItem(raw: string, sourcePath: string): Item {
|
export function parseItem(raw: string, sourcePath: string): Item {
|
||||||
const parsed = matter(raw)
|
const parsed = matter(raw)
|
||||||
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
|
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
|
||||||
const fm = itemFrontmatterSchema.parse(frontmatter)
|
const fm = itemFrontmatterSchema.parse(frontmatter)
|
||||||
const long = parsed.content.trim()
|
|
||||||
if (long.length === 0) {
|
// Split body into long-description prefix + sectioned remainder.
|
||||||
|
// The first `## key` header (if any) marks the boundary.
|
||||||
|
const body = parsed.content
|
||||||
|
const firstHeader = body.match(/^##\s+[\w-]+\s*$/m)
|
||||||
|
const longRaw = firstHeader ? body.slice(0, firstHeader.index!).trim() : body.trim()
|
||||||
|
if (longRaw.length === 0) {
|
||||||
throw new Error(`${sourcePath}: empty long description`)
|
throw new Error(`${sourcePath}: empty long description`)
|
||||||
}
|
}
|
||||||
return {
|
const sections = firstHeader ? splitSections(body.slice(firstHeader.index!)) : {}
|
||||||
|
|
||||||
|
// Validate that only known section keys appear.
|
||||||
|
for (const key of Object.keys(sections)) {
|
||||||
|
if (!ITEM_SECTION_KEYS.includes(key as ItemSectionKey)) {
|
||||||
|
throw new Error(`${sourcePath}: unknown item section "## ${key}". Allowed: ${ITEM_SECTION_KEYS.join(', ')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fm.readable && !sections['read']) {
|
||||||
|
throw new Error(`${sourcePath}: ## read section is required when readable: true`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const item: Item = {
|
||||||
id: fm.id,
|
id: fm.id,
|
||||||
names: fm.names,
|
names: fm.names,
|
||||||
short: fm.short,
|
short: fm.short,
|
||||||
long,
|
long: longRaw,
|
||||||
initialState: fm.initialState,
|
initialState: fm.initialState,
|
||||||
takeable: fm.takeable,
|
takeable: fm.takeable,
|
||||||
}
|
}
|
||||||
|
if (fm.readable !== undefined) item.readable = fm.readable
|
||||||
|
if (fm.lightable !== undefined) item.lightable = fm.lightable
|
||||||
|
if (fm.lighter !== undefined) item.lighter = fm.lighter
|
||||||
|
if (fm.lighterUses !== undefined) item.lighterUses = fm.lighterUses
|
||||||
|
if (sections['read']) item.readableText = sections['read']
|
||||||
|
if (sections['lit']) item.litText = sections['lit']
|
||||||
|
if (sections['extinguished']) item.extinguishedText = sections['extinguished']
|
||||||
|
if (sections['lighter-empty']) item.lighterEmptyText = sections['lighter-empty']
|
||||||
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedEnding {
|
export interface ParsedEnding {
|
||||||
id: 'true' | 'wrong' | 'bad'
|
id: 'true' | 'wrong' | 'bad'
|
||||||
ending: { whenFlags: Record<string, string | boolean | number>; narration: string }
|
ending: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseEnding(raw: string, _sourcePath: string): ParsedEnding {
|
export function parseEnding(raw: string, _sourcePath: string): ParsedEnding {
|
||||||
|
|||||||
@@ -79,3 +79,27 @@ describe('encounterFrontmatterSchema', () => {
|
|||||||
expect(() => encounterFrontmatterSchema.parse(data)).not.toThrow()
|
expect(() => encounterFrontmatterSchema.parse(data)).not.toThrow()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('itemFrontmatterSchema — bible additions', () => {
|
||||||
|
it('accepts readable + lighter fields', () => {
|
||||||
|
const data = {
|
||||||
|
id: 'matches',
|
||||||
|
names: ['matches', 'matchbook'],
|
||||||
|
short: 'a matchbook',
|
||||||
|
takeable: true,
|
||||||
|
lighter: true,
|
||||||
|
lighterUses: 4,
|
||||||
|
}
|
||||||
|
expect(() => itemFrontmatterSchema.parse(data)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts lightable on its own', () => {
|
||||||
|
const data = { id: 'lamp', names: ['lamp'], short: 'a lamp', takeable: true, lightable: true }
|
||||||
|
expect(() => itemFrontmatterSchema.parse(data)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects negative lighterUses', () => {
|
||||||
|
const data = { id: 'matches', names: ['matches'], short: 'matches', takeable: true, lighter: true, lighterUses: -1 }
|
||||||
|
expect(() => itemFrontmatterSchema.parse(data)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const stateValueSchema = z.union([z.string(), z.boolean(), z.number()])
|
const stateValueSchema = z.union([z.string(), z.boolean(), z.number(), z.array(z.string())])
|
||||||
const stateRecordSchema = z.record(z.string(), stateValueSchema)
|
const stateRecordSchema = z.record(z.string(), stateValueSchema)
|
||||||
|
|
||||||
export const roomFrontmatterSchema = z.object({
|
export const roomFrontmatterSchema = z.object({
|
||||||
@@ -37,6 +37,10 @@ export const itemFrontmatterSchema = z.object({
|
|||||||
short: z.string().min(1),
|
short: z.string().min(1),
|
||||||
takeable: z.boolean(),
|
takeable: z.boolean(),
|
||||||
initialState: stateRecordSchema.default({}),
|
initialState: stateRecordSchema.default({}),
|
||||||
|
readable: z.boolean().optional(),
|
||||||
|
lightable: z.boolean().optional(),
|
||||||
|
lighter: z.boolean().optional(),
|
||||||
|
lighterUses: z.number().int().nonnegative().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ItemFrontmatter = z.infer<typeof itemFrontmatterSchema>
|
export type ItemFrontmatter = z.infer<typeof itemFrontmatterSchema>
|
||||||
|
|||||||
+20
-4
@@ -37,9 +37,25 @@ export interface Item {
|
|||||||
/** Long description shown when examined. */
|
/** Long description shown when examined. */
|
||||||
long: string
|
long: string
|
||||||
/** Initial per-instance state (e.g. `{ lit: false }`). */
|
/** Initial per-instance state (e.g. `{ lit: false }`). */
|
||||||
initialState: Record<string, string | boolean | number>
|
initialState: Record<string, string | boolean | number | string[]>
|
||||||
/** True if the player can pick it up. */
|
/** True if the player can pick it up. */
|
||||||
takeable: boolean
|
takeable: boolean
|
||||||
|
/** True if `read X` should narrate the item's `## read` section. */
|
||||||
|
readable?: boolean
|
||||||
|
/** True if `light X` / `extinguish X` apply; toggles state.lit. */
|
||||||
|
lightable?: boolean
|
||||||
|
/** True if this item can light other items. */
|
||||||
|
lighter?: boolean
|
||||||
|
/** Optional remaining-charges counter; absent means unlimited. */
|
||||||
|
lighterUses?: number
|
||||||
|
/** Prose returned by `read X`. Required iff readable is true. */
|
||||||
|
readableText?: string
|
||||||
|
/** Prose narrated when `light X` succeeds. Falls back to "It catches." */
|
||||||
|
litText?: string
|
||||||
|
/** Prose narrated when `extinguish X` succeeds. Falls back to "The flame dies." */
|
||||||
|
extinguishedText?: string
|
||||||
|
/** Prose narrated when this item's lighterUses reaches 0. Falls back to "It is spent." */
|
||||||
|
lighterEmptyText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EncounterPhaseDef {
|
export interface EncounterPhaseDef {
|
||||||
@@ -83,8 +99,8 @@ export interface World {
|
|||||||
encounters: Record<EncounterId, EncounterDef>
|
encounters: Record<EncounterId, EncounterDef>
|
||||||
/** Story flag definitions and the endings they unlock. */
|
/** Story flag definitions and the endings they unlock. */
|
||||||
endings: {
|
endings: {
|
||||||
true: { whenFlags: Record<string, string | boolean | number>; narration: string }
|
true: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
|
||||||
wrong: { whenFlags: Record<string, string | boolean | number>; narration: string }
|
wrong: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
|
||||||
bad: { whenFlags: Record<string, string | boolean | number>; narration: string }
|
bad: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user