Compare commits
10 Commits
ab8c17fdd5
...
5f8e3b1a34
| 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.appended.some((l) => l.text.includes('locked'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a locked exit when required item is in inventory', () => {
|
||||
// Locked-exit-with-key happy path is covered by the playthrough integration
|
||||
// test in Task 8. The sample world above doesn't have an unlocked path to
|
||||
// pick up the brass key without first traversing the locked door, so this
|
||||
// test is intentionally a placeholder.
|
||||
expect(true).toBe(true)
|
||||
describe('locked exits', () => {
|
||||
function makeWorld(): World {
|
||||
return {
|
||||
startingRoom: 'antechamber',
|
||||
startingInventory: [],
|
||||
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: {},
|
||||
endings: {
|
||||
true: { whenFlags: {}, narration: '' },
|
||||
wrong: { whenFlags: {}, narration: '' },
|
||||
bad: { whenFlags: {}, narration: '' },
|
||||
true: { whenFlags: { _never: true }, narration: '' },
|
||||
wrong: { whenFlags: { _never: true }, 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()
|
||||
})
|
||||
})
|
||||
|
||||
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 {
|
||||
// Disambiguation reply: re-issue the original verb with the chosen target.
|
||||
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') {
|
||||
const text =
|
||||
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') {
|
||||
return handleGo(state, command.direction, world)
|
||||
return withEndingCheck(handleGo(state, command.direction, world), world)
|
||||
}
|
||||
|
||||
if (command.kind === 'ambiguous') {
|
||||
@@ -101,9 +134,9 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
|
||||
}
|
||||
|
||||
if (command.kind === 'verb-only') {
|
||||
if (command.verb === 'look') return handleLook(state, world)
|
||||
if (command.verb === 'inventory') return handleInventory(state, world)
|
||||
if (command.verb === 'wait') return narrate(state, [{ kind: 'narration', text: 'Time passes.' }])
|
||||
if (command.verb === 'look') return withEndingCheck(handleLook(state, world), world)
|
||||
if (command.verb === 'inventory') return withEndingCheck(handleInventory(state, world), world)
|
||||
if (command.verb === 'wait') return withEndingCheck(narrate(state, [{ kind: 'narration', text: 'Time passes.' }]), world)
|
||||
}
|
||||
|
||||
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'.
|
||||
const encResult = applyVerbToEncounter(stateWithNoun, command, world)
|
||||
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 === 'drop') return handleDrop(stateWithNoun, command.target.canonical, world)
|
||||
if (command.verb === 'examine' || command.verb === 'look') return handleExamine(stateWithNoun, command.target.canonical, world)
|
||||
return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }])
|
||||
if (command.verb === 'take') return withEndingCheck(handleTake(stateWithNoun, command.target.canonical, world), world)
|
||||
if (command.verb === 'drop') return withEndingCheck(handleDrop(stateWithNoun, command.target.canonical, world), world)
|
||||
if (command.verb === 'examine' || command.verb === 'look') return withEndingCheck(handleExamine(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 === '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.' }])
|
||||
@@ -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.' }])
|
||||
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: {
|
||||
true: { whenFlags: {}, narration: '' },
|
||||
wrong: { whenFlags: {}, narration: '' },
|
||||
bad: { whenFlags: {}, narration: '' },
|
||||
true: { whenFlags: { _never: true }, narration: '' },
|
||||
wrong: { whenFlags: { _never: true }, narration: '' },
|
||||
bad: { whenFlags: { _never: true }, narration: '' },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -66,12 +66,17 @@ export function applyVerbToEncounter(
|
||||
const phaseDef = def.phases[currentPhase]
|
||||
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 targetId: string | null = null
|
||||
let instrumentId: string | null = null
|
||||
if (command.kind === 'verb-target') {
|
||||
verb = command.verb
|
||||
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') {
|
||||
verb = command.verb
|
||||
} else {
|
||||
@@ -91,6 +96,7 @@ export function applyVerbToEncounter(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (t.requires && instrumentId && t.requires.item !== instrumentId) return false
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -169,3 +169,16 @@
|
||||
@media (pointer: coarse) {
|
||||
.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 room = world.rooms[s.location]
|
||||
const visibleNouns: { id: string; aliases: string[] }[] = []
|
||||
@@ -81,6 +89,7 @@ if (!transcriptEl || !inputEl) {
|
||||
|
||||
renderAll(state.transcript)
|
||||
refreshChips()
|
||||
syncEndedUI()
|
||||
inputEl.focus()
|
||||
|
||||
inputEl.addEventListener('keydown', (e) => {
|
||||
@@ -91,6 +100,15 @@ if (!transcriptEl || !inputEl) {
|
||||
if (!raw.trim()) return
|
||||
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.
|
||||
const trimmed = raw.trim().toLowerCase()
|
||||
if (trimmed === 'restart') {
|
||||
@@ -105,6 +123,7 @@ if (!transcriptEl || !inputEl) {
|
||||
renderAll(state.transcript)
|
||||
saveState(state)
|
||||
refreshChips()
|
||||
syncEndedUI()
|
||||
return
|
||||
}
|
||||
if (trimmed === 'undo') {
|
||||
@@ -114,6 +133,7 @@ if (!transcriptEl || !inputEl) {
|
||||
appendLines([{ kind: 'system', text: '(undone)' }])
|
||||
saveState(state)
|
||||
refreshChips()
|
||||
syncEndedUI()
|
||||
} else {
|
||||
appendLines([{ kind: 'system', text: 'There is no further back.' }])
|
||||
}
|
||||
@@ -139,6 +159,7 @@ if (!transcriptEl || !inputEl) {
|
||||
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
|
||||
}
|
||||
refreshChips()
|
||||
syncEndedUI()
|
||||
} catch (err) {
|
||||
console.error('[halfstreet] dispatch error', err)
|
||||
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
id: bad
|
||||
whenFlags: {}
|
||||
whenFlags:
|
||||
_never: true
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
id: wrong
|
||||
whenFlags: {}
|
||||
whenFlags:
|
||||
_never: true
|
||||
---
|
||||
|
||||
|
||||
@@ -3,8 +3,15 @@ id: lamp
|
||||
names: ["lamp", "oil lamp", "torch"]
|
||||
short: "an oil lamp"
|
||||
takeable: true
|
||||
lightable: true
|
||||
initialState:
|
||||
lit: false
|
||||
---
|
||||
|
||||
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"]
|
||||
short: "a folded letter"
|
||||
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."
|
||||
|
||||
@@ -3,6 +3,13 @@ id: matches
|
||||
names: ["matches", "safety matches", "box"]
|
||||
short: "a box of safety matches"
|
||||
takeable: true
|
||||
lighter: true
|
||||
lighterUses: 4
|
||||
initialState:
|
||||
uses: 4
|
||||
---
|
||||
|
||||
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', () => {
|
||||
it('throws a clear error when a header has spaces', () => {
|
||||
const md = `---
|
||||
|
||||
+35
-5
@@ -130,27 +130,57 @@ export function parseRoom(raw: string, sourcePath: string): 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 {
|
||||
const parsed = matter(raw)
|
||||
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
|
||||
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`)
|
||||
}
|
||||
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,
|
||||
names: fm.names,
|
||||
short: fm.short,
|
||||
long,
|
||||
long: longRaw,
|
||||
initialState: fm.initialState,
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -79,3 +79,27 @@ describe('encounterFrontmatterSchema', () => {
|
||||
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'
|
||||
|
||||
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)
|
||||
|
||||
export const roomFrontmatterSchema = z.object({
|
||||
@@ -37,6 +37,10 @@ export const itemFrontmatterSchema = z.object({
|
||||
short: z.string().min(1),
|
||||
takeable: z.boolean(),
|
||||
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>
|
||||
|
||||
+20
-4
@@ -37,9 +37,25 @@ export interface Item {
|
||||
/** Long description shown when examined. */
|
||||
long: string
|
||||
/** 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. */
|
||||
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 {
|
||||
@@ -83,8 +99,8 @@ export interface World {
|
||||
encounters: Record<EncounterId, EncounterDef>
|
||||
/** Story flag definitions and the endings they unlock. */
|
||||
endings: {
|
||||
true: { whenFlags: Record<string, string | boolean | number>; narration: string }
|
||||
wrong: { whenFlags: Record<string, string | boolean | number>; narration: string }
|
||||
bad: { 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 | string[]>; narration: string }
|
||||
bad: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user