feat(ui): add confirmations and terminal motion
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -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.'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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]! }
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -13,6 +13,7 @@ const baseState = (overrides: Partial<GameState> = {}): GameState => ({
|
||||
encounterState: {},
|
||||
lastNoun: null,
|
||||
pendingDisambiguation: null,
|
||||
pendingConfirmation: null,
|
||||
transcript: [],
|
||||
endedWith: null,
|
||||
...overrides,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user