diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index cccf75e..21583da 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -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.' diff --git a/src/engine/encounters.test.ts b/src/engine/encounters.test.ts index 574dd57..2f1ff8e 100644 --- a/src/engine/encounters.test.ts +++ b/src/engine/encounters.test.ts @@ -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) }) diff --git a/src/engine/parser.test.ts b/src/engine/parser.test.ts index f0e42fe..803a35f 100644 --- a/src/engine/parser.test.ts +++ b/src/engine/parser.test.ts @@ -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' }) diff --git a/src/engine/parser.ts b/src/engine/parser.ts index a179e24..c6567f7 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -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]! } diff --git a/src/engine/playthrough.test.ts b/src/engine/playthrough.test.ts index 234b135..73222a2 100644 --- a/src/engine/playthrough.test.ts +++ b/src/engine/playthrough.test.ts @@ -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') diff --git a/src/engine/save.test.ts b/src/engine/save.test.ts index 7ac5886..2a01602 100644 --- a/src/engine/save.test.ts +++ b/src/engine/save.test.ts @@ -13,6 +13,7 @@ const baseState = (overrides: Partial = {}): GameState => ({ encounterState: {}, lastNoun: null, pendingDisambiguation: null, + pendingConfirmation: null, transcript: [], endedWith: null, ...overrides, diff --git a/src/engine/types.ts b/src/engine/types.ts index 78a37e0..01b6719 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -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. */ diff --git a/src/ui/chips.test.ts b/src/ui/chips.test.ts index f790c6f..6344e0c 100644 --- a/src/ui/chips.test.ts +++ b/src/ui/chips.test.ts @@ -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 }, + ]) + }) }) diff --git a/src/ui/chips.ts b/src/ui/chips.ts index 97e3e75..d43ea64 100644 --- a/src/ui/chips.ts +++ b/src/ui/chips.ts @@ -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) { diff --git a/src/ui/crt.css b/src/ui/crt.css index 5aaffc5..674b88a 100644 --- a/src/ui/crt.css +++ b/src/ui/crt.css @@ -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; diff --git a/src/ui/terminal.ts b/src/ui/terminal.ts index 29f4595..9ecc588 100644 --- a/src/ui/terminal.ts +++ b/src/ui/terminal.ts @@ -44,6 +44,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) { let historyIndex: number | null = null let historyDraft = '' let idleHintTimer: number | null = null + let renderQueue: Promise = 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 => + 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 => { + 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 => { 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 => { + 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 }) } }) diff --git a/src/world/TODOs.md b/src/world/TODOs.md index 6b289bb..e66331d 100644 --- a/src/world/TODOs.md +++ b/src/world/TODOs.md @@ -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.