diff --git a/src/engine/dispatcher.test.ts b/src/engine/dispatcher.test.ts index 1931815..0f9d0d1 100644 --- a/src/engine/dispatcher.test.ts +++ b/src/engine/dispatcher.test.ts @@ -122,7 +122,7 @@ describe('locked exits', () => { 'rusted-key': { id: 'rusted-key', names: ['rusted key', 'key'], short: 'a rusted key', long: '.', initialState: {}, takeable: true }, }, 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: {}, 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: '' }, }, } } @@ -271,9 +271,9 @@ function readWorld(): World { }, 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: '' }, }, } } @@ -312,7 +312,7 @@ describe('light/extinguish verbs (implicit lighter)', () => { rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true }, }, 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 }, }, 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 }, }, 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.") }) }) + +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() + }) +}) diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index 6aefdd4..70cf4d6 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -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,16 +144,16 @@ 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) - if (command.verb === 'read') return handleRead(stateWithNoun, command.target.canonical, world) - if (command.verb === 'light') return handleLight(stateWithNoun, command.target.canonical, null, world) - if (command.verb === 'extinguish') return handleExtinguish(stateWithNoun, command.target.canonical, world) - if (command.verb === 'use') return narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]) - 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') { @@ -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'. 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 === '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') { - 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.' }]) diff --git a/src/engine/encounters.test.ts b/src/engine/encounters.test.ts index 31dbada..7839864 100644 --- a/src/engine/encounters.test.ts +++ b/src/engine/encounters.test.ts @@ -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: '' }, }, } diff --git a/src/world/endings/bad.md b/src/world/endings/bad.md index 2ecd732..59e5a3f 100644 --- a/src/world/endings/bad.md +++ b/src/world/endings/bad.md @@ -1,5 +1,6 @@ --- id: bad -whenFlags: {} +whenFlags: + _never: true --- diff --git a/src/world/endings/wrong.md b/src/world/endings/wrong.md index e78c958..d87eb0e 100644 --- a/src/world/endings/wrong.md +++ b/src/world/endings/wrong.md @@ -1,5 +1,6 @@ --- id: wrong -whenFlags: {} +whenFlags: + _never: true ---