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. */
+15
View File
@@ -48,4 +48,19 @@ describe('computeChips — sample world', () => {
expect(chips.find((c) => c.command === 'wait')).toBeTruthy()
expect(chips.find((c) => c.command === 'help')).toBeTruthy()
})
it('shows only confirmation chips while a dangerous command is pending', () => {
const s = {
...initialStateFor(world),
pendingConfirmation: {
command: { kind: 'verb-target' as const, verb: 'attack' as const, target: { canonical: 'rat', raw: 'rat' } },
prompt: 'Are you sure?',
},
}
const chips = computeChips(s, world)
expect(chips).toEqual([
{ kind: 'meta', label: 'YES', command: 'yes', disabled: false },
{ kind: 'meta', label: 'NO', command: 'no', disabled: false },
])
})
})
+7
View File
@@ -20,6 +20,13 @@ export function computeChips(state: GameState, world: World): Chip[] {
const room = world.rooms[state.location]
if (!room) return out
if (state.pendingConfirmation) {
return [
{ kind: 'meta', label: 'YES', command: 'yes', disabled: false },
{ kind: 'meta', label: 'NO', command: 'no', disabled: false },
]
}
// Direction chips: enabled if exit exists, dimmed otherwise.
const dirs: Direction[] = ['n', 's', 'e', 'w', 'u', 'd']
for (const d of dirs) {
+11
View File
@@ -204,6 +204,7 @@ body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-anchor: none;
overscroll-behavior: contain;
scrollbar-color: var(--m-dim) var(--m-bg);
scrollbar-width: thin;
@@ -239,6 +240,16 @@ body {
margin-top: 0.6em;
}
.mystery-transcript .room-title {
scroll-margin-top: 0;
}
.mystery-transcript .room-scroll-spacer {
max-width: none;
margin: 0;
pointer-events: none;
}
.mystery-transcript .ascii-art {
max-width: 100%;
overflow-x: auto;
+134 -19
View File
@@ -44,6 +44,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
let historyIndex: number | null = null
let historyDraft = ''
let idleHintTimer: number | null = null
let renderQueue: Promise<void> = Promise.resolve()
let renderGeneration = 0
let roomScrollSpacer: HTMLDivElement | null = null
const TYPE_INTERVAL_MS = 8
const TYPE_CHARS_PER_TICK = 3
const ROOM_SCROLL_MS = 180
const syncLightMeter = (): void => {
if (!lightMeterEl) return
@@ -152,18 +159,123 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
}
}
const renderAll = (lines: TranscriptLine[]): void => {
const wait = (ms: number): Promise<void> =>
new Promise((resolve) => {
window.setTimeout(resolve, ms)
})
const isAsciiArtLine = (line: TranscriptLine): boolean =>
line.kind === 'system' && line.text.includes('|_| |_|')
const isRoomTitleLine = (line: TranscriptLine): boolean => {
if (line.kind !== 'system') return false
const trimmed = line.text.trim()
return /^\[\s*.+\s*\]$/.test(trimmed) && !trimmed.includes('|')
}
const typeLine = async (el: HTMLDivElement, text: string): Promise<void> => {
el.textContent = ''
for (let i = TYPE_CHARS_PER_TICK; i < text.length; i += TYPE_CHARS_PER_TICK) {
el.textContent = text.slice(0, i)
await wait(TYPE_INTERVAL_MS)
}
el.textContent = text
}
const scrollToTopOf = async (el: HTMLElement): Promise<void> => {
if (!transcriptEl) return
transcriptEl.scrollTo({ top: Math.max(0, el.offsetTop), behavior: 'smooth' })
await wait(ROOM_SCROLL_MS)
}
const updateRoomScrollSpacer = (anchor?: HTMLElement): void => {
if (!transcriptEl) return
if (!roomScrollSpacer) {
roomScrollSpacer = document.createElement('div')
roomScrollSpacer.className = 'room-scroll-spacer'
roomScrollSpacer.setAttribute('aria-hidden', 'true')
}
const spacerHeight = Math.max(0, transcriptEl.clientHeight - (anchor?.offsetHeight ?? 0))
roomScrollSpacer.style.height = `${spacerHeight}px`
if (roomScrollSpacer.parentElement !== transcriptEl) {
transcriptEl.appendChild(roomScrollSpacer)
}
}
const scrollToContentBottom = (): void => {
if (!transcriptEl) return
updateRoomScrollSpacer()
const contentBottom = roomScrollSpacer?.offsetTop ?? transcriptEl.scrollHeight
transcriptEl.scrollTop = Math.max(0, contentBottom - transcriptEl.clientHeight)
}
const appendTranscriptElement = (el: HTMLDivElement): void => {
if (!transcriptEl) return
updateRoomScrollSpacer()
if (roomScrollSpacer?.parentElement === transcriptEl) {
transcriptEl.insertBefore(el, roomScrollSpacer)
return
}
transcriptEl.appendChild(el)
}
const renderLines = async (
lines: TranscriptLine[],
animate: boolean,
shouldScroll: boolean,
generation: number,
): Promise<void> => {
if (!transcriptEl) return
const roomTitleInBatch = shouldScroll && animate && lines.some(isRoomTitleLine)
let roomTitleEl: HTMLDivElement | null = null
for (const line of lines) {
if (generation !== renderGeneration) return
const el = document.createElement('div')
el.className = line.kind
if (line.kind === 'system' && line.text.includes('|_| |_|')) {
const asciiArt = isAsciiArtLine(line)
const roomTitle = roomTitleInBatch && isRoomTitleLine(line)
if (asciiArt) {
el.classList.add('ascii-art')
}
el.textContent = line.text
transcriptEl.appendChild(el)
if (isRoomTitleLine(line)) {
el.classList.add('room-title')
}
const shouldType = animate && line.kind !== 'player' && !asciiArt && !roomTitle
el.textContent = shouldType ? '' : line.text
appendTranscriptElement(el)
if (roomTitle) {
roomTitleEl = el
updateRoomScrollSpacer(el)
await scrollToTopOf(el)
if (generation !== renderGeneration) return
}
if (shouldType) {
await typeLine(el, line.text)
if (generation !== renderGeneration) return
}
if (shouldScroll && !roomTitleInBatch) {
scrollToContentBottom()
}
}
transcriptEl.scrollTop = transcriptEl.scrollHeight
if (shouldScroll && !roomTitleEl) {
scrollToContentBottom()
}
}
const renderAll = (lines: TranscriptLine[], options: { animate?: boolean; scroll?: boolean } = {}): void => {
const animate = options.animate ?? true
const shouldScroll = options.scroll ?? true
const generation = renderGeneration
renderQueue = renderQueue.then(() => renderLines(lines, animate, shouldScroll, generation)).catch((err) => {
console.error('[halfstreet] render error', err)
})
}
const clearIdleHint = (): void => {
@@ -211,9 +323,9 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
text.className = 'mystery-help-body'
text.textContent = HELP_TEXT
el.append(close, text)
transcriptEl.appendChild(el)
appendTranscriptElement(el)
transientHelpEl = el
transcriptEl.scrollTop = transcriptEl.scrollHeight
scrollToContentBottom()
}
document.addEventListener('pointerdown', (e) => {
@@ -234,30 +346,32 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
// notices). Pushes into state.transcript so they survive reload, then renders.
// Engine-originated lines (from dispatch) are already in state.transcript;
// those use renderAll directly.
const appendLines = (lines: TranscriptLine[]): void => {
const appendLines = (lines: TranscriptLine[], options: { animate?: boolean; scroll?: boolean } = {}): void => {
state = { ...state, transcript: [...state.transcript, ...lines].slice(-TRANSCRIPT_CAP) }
renderAll(lines)
renderAll(lines, options)
}
const restart = (): void => {
const confirmed = confirm('Restart? Your progress will be lost.')
if (!confirmed) {
appendLines([{ kind: 'system', text: '(restart cancelled)' }])
appendLines([{ kind: 'system', text: '(restart cancelled)' }], { scroll: false })
return
}
clearSave()
state = initialStateFor(world)
renderGeneration += 1
renderQueue = Promise.resolve()
transcriptEl.innerHTML = ''
inputEl.value = ''
syncCommandLine()
renderAll(state.transcript)
renderAll(state.transcript, { animate: false })
saveState(state)
refreshChips()
syncLightMeter()
syncEndedUI()
}
renderAll(state.transcript)
renderAll(state.transcript, { animate: false })
refreshChips()
syncLightMeter()
syncEndedUI()
@@ -293,13 +407,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
commandHistory = [...commandHistory, raw].slice(-50)
historyIndex = null
historyDraft = ''
appendLines([{ kind: 'player', text: raw }])
appendLines([{ kind: 'player', text: raw }], { scroll: false })
// 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`.' }])
appendLines([{ kind: 'system', text: 'The story has ended. Type `restart` or `undo`.' }], { scroll: false })
return
}
}
@@ -318,13 +432,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
if (lastState) {
state = lastState
lastState = null
appendLines([{ kind: 'system', text: '(undone)' }])
appendLines([{ kind: 'system', text: '(undone)' }], { scroll: false })
saveState(state)
refreshChips()
syncLightMeter()
syncEndedUI()
} else {
appendLines([{ kind: 'system', text: 'There is no further back.' }])
appendLines([{ kind: 'system', text: 'There is no further back.' }], { scroll: false })
}
return
}
@@ -339,11 +453,12 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
const ctx = buildParserContext(state)
const command = parse(raw, ctx)
lastState = state
const previousLocation = state.location
const result = dispatch(state, command, world)
state = result.state
renderAll(result.appended) // dispatch already pushed these into state.transcript
const shouldScrollToRoom = command.kind === 'go' && state.location !== previousLocation
renderAll(result.appended, { scroll: shouldScrollToRoom }) // dispatch already pushed these into state.transcript
saveState(state)
transcriptEl.scrollTop = transcriptEl.scrollHeight
if (raw.trim().toLowerCase() === 'theme') {
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
}
@@ -352,7 +467,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
syncEndedUI()
} catch (err) {
console.error('[halfstreet] dispatch error', err)
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }])
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }], { scroll: false })
}
})
+3 -2
View File
@@ -24,7 +24,7 @@
- [x] If the user says "light match" or "light match" the response should be "use match with what?"
- [x] If the user says "use match with letter" they should burn the letter.
- [x] There should be a lighter in the smoking room that allows unlimited lighting.
- [ ] Create a mechanic that asks "Are you sure?" before taking critical actions like attacking or other game-changing mechanics that might affect the final ending.
- [x] Create a mechanic that asks "Are you sure?" before taking critical actions like attacking or other game-changing mechanics that might affect the final ending.
- [ ] Add lightened descriptions to darkened rooms. About half the rooms should be too dark to see anything (affects ability to move forward, can't see exits or encounters, except for maybe hints at the encounters, like sounds or shapes in the dark) Add frontmatter property to all rooms: (dark: true/false). Make text in darkened rooms a grey color.
- [ ] Implement a simple "stealth mechanic", where sometimes it's advantageous to have the light out.
- [ ] Implement a simple (optional?) minimap in the UI? - Maybe tied to an item? Once you get the map the minimap appears? Can we POC it?
@@ -44,4 +44,5 @@
- [ ] Add item Whiskey bottle, half full of something smoky. In the kitchen. Drinking it gets the player drunk, which causes them to unlock the drunk rooms, which are a series of rooms where they can go many directions, but somehow end up in seemingly back in the same spot. They are essentially a maze of doors and hallways, ladders and levels. There should be boundaries established though, it shouldn't be endless. After 20 or so moves the player passes out and wakes up back in the foyer, with the whiskey bottle returned to the kitchen, somehow still half full. Add a random encounter in the dark rooms, a creaking floorboard helps you find a secret door that opens with a faceless man inside who gives you major plot info. After this encounter you pass out and wake up in the lobby as before.
- [ ] Set up BugPin as a self-hosted visual bug reporter for the site, then have incoming reports create markdown files under `src/world/bugs/` via a webhook or API bridge so bugs can be tracked in git alongside the game content. Include screenshot/annotation metadata in the markdown and decide whether these bug docs stay outside the world loader or get their own loader later.
- [ ] BUG: It says the door closes behind you when you enter the lobby, but you can still exit S to the gate.
- [x] FEATURE: Add a short "typed" effect to the text. Make it look like it's being typed out, if that makes sense, one character at a time. The effect should be brief.
- [x] FEATURE: Whenever you change rooms, scroll the text so the name of the room you're in is at the top. Users can scroll up to see the history. This should be an effect where the old text slides up to make room for the new text, and this should happen before the "typed" effect.