docs(mystery): spec for engine prereqs (verbs, disambiguation, ending UI) #1
@@ -122,7 +122,7 @@ describe('locked exits', () => {
|
|||||||
'rusted-key': { id: 'rusted-key', names: ['rusted key', 'key'], short: 'a rusted key', long: '.', initialState: {}, takeable: true },
|
'rusted-key': { id: 'rusted-key', names: ['rusted key', 'key'], short: 'a rusted key', long: '.', initialState: {}, takeable: true },
|
||||||
},
|
},
|
||||||
encounters: {},
|
encounters: {},
|
||||||
endings: { true: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' } },
|
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,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: '' },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,9 +271,9 @@ function readWorld(): World {
|
|||||||
},
|
},
|
||||||
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: '' },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,7 +312,7 @@ describe('light/extinguish verbs (implicit lighter)', () => {
|
|||||||
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
|
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
|
||||||
},
|
},
|
||||||
encounters: {},
|
encounters: {},
|
||||||
endings: { true: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' } },
|
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +400,7 @@ describe('light X with Y (explicit lighter)', () => {
|
|||||||
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
|
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
|
||||||
},
|
},
|
||||||
encounters: {},
|
encounters: {},
|
||||||
endings: { true: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' } },
|
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +448,7 @@ describe('use verb routing', () => {
|
|||||||
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
|
rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true },
|
||||||
},
|
},
|
||||||
encounters: {},
|
encounters: {},
|
||||||
endings: { true: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' } },
|
endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,3 +462,71 @@ describe('use verb routing', () => {
|
|||||||
expect(result.appended.at(-1)?.text).toBe("You can't think how to use that here.")
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+50
-17
@@ -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,16 +144,16 @@ 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)
|
||||||
if (command.verb === 'read') return handleRead(stateWithNoun, command.target.canonical, world)
|
if (command.verb === 'read') return withEndingCheck(handleRead(stateWithNoun, command.target.canonical, world), world)
|
||||||
if (command.verb === 'light') return handleLight(stateWithNoun, command.target.canonical, null, world)
|
if (command.verb === 'light') return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, null, world), world)
|
||||||
if (command.verb === 'extinguish') return handleExtinguish(stateWithNoun, command.target.canonical, world)
|
if (command.verb === 'extinguish') return withEndingCheck(handleExtinguish(stateWithNoun, command.target.canonical, world), world)
|
||||||
if (command.verb === 'use') return narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }])
|
if (command.verb === 'use') return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
|
||||||
return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }])
|
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.kind === 'verb-target-prep') {
|
if (command.kind === 'verb-target-prep') {
|
||||||
@@ -128,15 +161,15 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
|
|||||||
// Try the encounter first — it may consume verbs like 'cut vines with shears'.
|
// Try the encounter first — it may consume verbs like 'cut vines with shears'.
|
||||||
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 === 'light' && command.preposition === 'with') {
|
if (command.verb === 'light' && command.preposition === 'with') {
|
||||||
return handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world)
|
return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world), world)
|
||||||
}
|
}
|
||||||
if (command.verb === 'use') {
|
if (command.verb === 'use') {
|
||||||
return narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }])
|
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
|
||||||
}
|
}
|
||||||
return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }])
|
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.' }])
|
||||||
|
|||||||
@@ -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: '' },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
id: bad
|
id: bad
|
||||||
whenFlags: {}
|
whenFlags:
|
||||||
|
_never: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
id: wrong
|
id: wrong
|
||||||
whenFlags: {}
|
whenFlags:
|
||||||
|
_never: true
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user