feat(ui): add confirmations and terminal motion
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-05-12 19:44:18 -05:00
parent cc98aa180b
commit 0755213d6a
12 changed files with 263 additions and 29 deletions
+47 -6
View File
@@ -46,6 +46,7 @@ export function initialStateFor(world: World): GameState {
encounterState: {},
lastNoun: null,
pendingDisambiguation: null,
pendingConfirmation: null,
transcript: opening,
endedWith: null,
}
@@ -120,7 +121,51 @@ function withEndingCheck(result: DispatchResult, world: World): DispatchResult {
return { state: updated, appended: [...result.appended, endingLine] }
}
export function dispatch(state: GameState, command: ParsedCommand, world: World): DispatchResult {
const CRITICAL_VERBS = new Set(['attack'])
function isCriticalCommand(command: ParsedCommand): boolean {
if (command.kind !== 'verb-target' && command.kind !== 'verb-target-prep') return false
return CRITICAL_VERBS.has(command.verb)
}
function confirmationPrompt(command: ParsedCommand): string {
if (command.kind === 'verb-target') {
return `Are you sure you want to ${command.verb} ${command.target.raw}? Type yes to continue, or no to stop.`
}
if (command.kind === 'verb-target-prep') {
return `Are you sure you want to ${command.verb} ${command.target.raw} ${command.preposition} ${command.indirect.raw}? Type yes to continue, or no to stop.`
}
return 'Are you sure? Type yes to continue, or no to stop.'
}
export function dispatch(state: GameState, command: ParsedCommand, world: World, confirmed = false): DispatchResult {
if (command.kind === 'confirmation') {
const pending = state.pendingConfirmation
if (!pending) {
return narrate(state, [{ kind: 'narration', text: 'Nothing to confirm.' }])
}
const cleared: GameState = { ...state, pendingConfirmation: null }
if (!command.confirmed) {
return narrate(cleared, [{ kind: 'narration', text: 'Cancelled.' }])
}
return dispatch(cleared, pending.command, world, true)
}
if (state.pendingConfirmation) {
state = { ...state, pendingConfirmation: null }
}
// 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 (!confirmed && isCriticalCommand(command)) {
const prompt = confirmationPrompt(command)
const next: GameState = { ...state, pendingConfirmation: { command, prompt } }
return narrate(next, [{ kind: 'narration', text: prompt }])
}
// Disambiguation reply: re-issue the original verb with the chosen target.
if (command.kind === 'disambiguation') {
const pending = state.pendingDisambiguation
@@ -132,14 +177,10 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
cleared,
{ kind: 'verb-target', verb: pending.verb, target: { canonical: command.chosen, raw: command.chosen } },
world,
confirmed,
)
}
// 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.'
+17 -2
View File
@@ -112,11 +112,25 @@ describe('encounters — phase advancement', () => {
it('wrong verb costs resolve and surfaces a clue', () => {
let s = initialStateFor(world)
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
const r = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
const prompt = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
expect(prompt.state.pendingConfirmation).toBeDefined()
expect(prompt.appended.at(-1)?.text).toContain('Are you sure')
const r = dispatch(prompt.state, { kind: 'confirmation', confirmed: true }, world)
expect(r.state.resolveLevel).toBe('shaken')
expect(r.state.encounterState['revenant']).toBe('shaken')
})
it('cancels a confirmed attack when the player says no', () => {
let s = initialStateFor(world)
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
const prompt = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
const r = dispatch(prompt.state, { kind: 'confirmation', confirmed: false }, world)
expect(r.state.pendingConfirmation).toBeNull()
expect(r.state.resolveLevel).toBe('steady')
expect(r.state.encounterState['revenant']).toBe('wary')
expect(r.appended.at(-1)?.text).toBe('Cancelled.')
})
it('falls back to defaultWrongVerbNarration for unrecognized verbs', () => {
let s = initialStateFor(world)
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
@@ -129,7 +143,8 @@ describe('encounters — phase advancement', () => {
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
// Force resolve to 'returning' so the next failure retreats.
s = { ...s, resolveLevel: 'returning' }
const r = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
s = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world).state
const r = dispatch(s, { kind: 'confirmation', confirmed: true }, world)
expect(r.state.location).toBe('foyer')
expect(r.appended.some((l) => l.text.includes('stagger back'))).toBe(true)
})
+12
View File
@@ -36,6 +36,18 @@ describe('parser — verb-only commands', () => {
})
})
describe('parser — confirmations', () => {
it('recognizes yes and no confirmation replies', () => {
expect(parse('yes', emptyCtx)).toEqual({ kind: 'confirmation', confirmed: true })
expect(parse('y', emptyCtx)).toEqual({ kind: 'confirmation', confirmed: true })
expect(parse('no', emptyCtx)).toEqual({ kind: 'confirmation', confirmed: false })
})
it('keeps n as the north direction shortcut', () => {
expect(parse('n', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
})
})
describe('parser — direction shortcuts', () => {
it('maps single-letter directions', () => {
expect(parse('n', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
+7
View File
@@ -108,6 +108,13 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
const tokens = tokenize(trimmed)
const head = tokens[0]!
if (tokens.length === 1 && ['yes', 'y'].includes(head)) {
return { kind: 'confirmation', confirmed: true }
}
if (tokens.length === 1 && head === 'no') {
return { kind: 'confirmation', confirmed: false }
}
// Meta-commands take precedence (single-word).
if (META_VERBS[head] && tokens.length === 1) {
return { kind: 'meta', verb: META_VERBS[head]! }
+1
View File
@@ -51,6 +51,7 @@ describe('playthrough — sample world', () => {
'take lamp',
'e', // hallway → cellar-stair (triggers rat encounter)
'attack rat',
'yes',
])
expect(state.flags['ratGone']).toBe(true)
expect(state.location).toBe('cellar-stair')
+1
View File
@@ -13,6 +13,7 @@ const baseState = (overrides: Partial<GameState> = {}): GameState => ({
encounterState: {},
lastNoun: null,
pendingDisambiguation: null,
pendingConfirmation: null,
transcript: [],
endedWith: null,
...overrides,
+8
View File
@@ -21,6 +21,7 @@ export interface NounRef {
}
export type ParsedCommand =
| { kind: 'confirmation'; confirmed: boolean }
| { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' | 'listen' }
| { kind: 'verb-target'; verb: Verb; target: NounRef }
| { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef }
@@ -51,6 +52,11 @@ export interface PendingDisambiguation {
prompt: string
}
export interface PendingConfirmation {
command: ParsedCommand
prompt: string
}
export interface GameState {
schemaVersion: number
location: RoomId
@@ -66,6 +72,8 @@ export interface GameState {
lastNoun: NounRef | null
/** Pending multi-word disambiguation, set when the parser cannot decide. */
pendingDisambiguation: PendingDisambiguation | null
/** Pending confirmation for dangerous/game-changing commands. */
pendingConfirmation: PendingConfirmation | null
/** Capped at 200 entries; older entries are dropped on append. */
transcript: TranscriptLine[]
/** Set true when the player has reached an ending. UI shows ending screen. */