Compare commits

...

10 Commits

Author SHA1 Message Date
ejlewis 5f8e3b1a34 fix(ui): keep input typable post-end so player can type restart/undo
Disabling the input via the disabled attribute blocks keydown events
entirely, so players couldn't type 'restart' or 'undo' after reaching an
ending. Switch to a CSS class for the faded visual state; the keydown
handler already restricts post-end input to those two commands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 15:06:37 -05:00
ejlewis e167979fa7 feat(ui): render ending lines distinctly and lock input on end-state
Ending-kind lines get a separator and italic styling. Once endedWith is
set, the terminal disables the input and rejects all commands except
restart and undo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:57:52 -05:00
ejlewis 19d1efc586 feat(engine): detect endings on every successful turn
After each state-mutating dispatch, evaluate world.endings in priority
order (true > wrong > bad). The first whose whenFlags are all satisfied
sets state.endedWith and appends a kind:'ending' transcript line. Once
ended, further dispatches return a "story has ended" narration.

Also update test-world fixtures and placeholder ending markdown files
to use whenFlags: { _never: true } instead of {} so that vacuously-true
empty flags don't accidentally fire on every successful turn.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:53:07 -05:00
ejlewis 0d9db9bb55 test(engine): self-contained locked-exit fixture replaces the stub
Verifies blocked movement, key-permitted passage, and that the key is
not consumed by passing through.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:42:22 -05:00
ejlewis b870d884ef feat(engine): wire verb-target-prep — explicit \light X with Y\ and \use\ routing
light X with Y validates the named instrument and reuses handleLight.
use X / use X on Y route through the encounter dispatcher; if no encounter
consumes it, the dispatcher narrates the fallback. The encounter matcher
also rejects transitions whose required item doesn't match the typed
instrument, so a mistyped instrument fails cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:22:03 -05:00
ejlewis 8401e7d281 feat(engine): light/extinguish verbs with implicit lighter selection
\`light X\` finds a lighter (item with lighter:true and remaining state.uses)
in inventory, decrements its charges, and toggles target.state.lit. The
target's litText / extinguishedText / the lighter's lighterEmptyText
provide narration. Refuses politely on each error path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:18:54 -05:00
ejlewis dac8487dbe feat(engine): read verb narrates item.readableText
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:15:35 -05:00
ejlewis 2fecc7878d feat(world): annotate lamp/matches/letter for read/light/extinguish
Adds the new schema flags and per-state body sections so the dispatcher's
new verb handlers have content to narrate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:12:10 -05:00
ejlewis ee3cfcc00d feat(world): parseItem extracts optional ## read / lit / extinguished / lighter-empty sections
Existing items with no body sections continue to load unchanged. New items
can author per-state prose in dedicated sections; the dispatcher will read
these in subsequent commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:08:33 -05:00
ejlewis df50afa479 feat(world): item schema — readable, lightable, lighter, lighterUses
Optional fields used by the new read/light/extinguish dispatcher branches.
Loader updates and dispatcher logic follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:01:31 -05:00
16 changed files with 737 additions and 34 deletions
+327 -9
View File
@@ -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
View File
@@ -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.' }])
}
+3 -3
View File
@@ -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: '' },
},
}
+7 -1
View File
@@ -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
})
+13
View File
@@ -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;
}
+21
View File
@@ -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. ]' }])
+2 -1
View File
@@ -1,5 +1,6 @@
---
id: bad
whenFlags: {}
whenFlags:
_never: true
---
+2 -1
View File
@@ -1,5 +1,6 @@
---
id: wrong
whenFlags: {}
whenFlags:
_never: true
---
+7
View File
@@ -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.
+4
View File
@@ -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."
+7
View File
@@ -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.
+112
View File
@@ -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
View File
@@ -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 {
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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 }
}
}