From 5f5dc6071bad624a4f943134375ab9d3bf85441d Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Fri, 8 May 2026 23:45:10 -0500 Subject: [PATCH] =?UTF-8?q?feat(mystery):=20terminal=20=E2=80=94=20input?= =?UTF-8?q?=20handling,=20dispatch=20wiring,=20autosave?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/terminal.ts | 138 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 130 insertions(+), 8 deletions(-) diff --git a/src/ui/terminal.ts b/src/ui/terminal.ts index 2cc0209..bf54265 100644 --- a/src/ui/terminal.ts +++ b/src/ui/terminal.ts @@ -1,10 +1,132 @@ -// Stub. Replaced in Task 10 with the full terminal implementation. -console.info('[halfstreet] terminal placeholder loaded') +import { parse } from '../engine/parser' +import type { ParserContext } from '../engine/parser' +import { dispatch, initialStateFor } from '../engine/dispatcher' +import { saveState, loadState, clearSave } from '../engine/save' +import { world } from '../world' +import type { GameState, TranscriptLine } from '../engine/types' -const transcript = document.querySelector('[data-mystery-transcript]') -if (transcript) { - const line = document.createElement('div') - line.className = 'narration' - line.textContent = 'Terminal scaffold loaded. Type wiring lands in the next task.' - transcript.appendChild(line) +const transcriptEl = document.querySelector('[data-mystery-transcript]') +const inputEl = document.querySelector('[data-mystery-input]') + +if (!transcriptEl || !inputEl) { + console.error('[halfstreet] terminal mount points missing') +} else { + const restored = loadState() + let state: GameState = restored ?? initialStateFor(world) + let lastState: GameState | null = null // for one-step undo + + if (!restored) { + // Fresh state already includes the opening narration in its transcript. + } else if (restored.transcript.length === 0) { + // Edge case: a restored state with no transcript (older save discarded + // and we fell back to fresh — handled above — or a corrupted slice). + state = initialStateFor(world) + } + + renderAll(state.transcript) + inputEl.focus() + + inputEl.addEventListener('keydown', (e) => { + if (e.key !== 'Enter') return + e.preventDefault() + const raw = inputEl.value + inputEl.value = '' + if (!raw.trim()) return + appendLines([{ kind: 'player', text: raw }]) + + // Engine-level meta-commands handled here so the engine stays pure. + const trimmed = raw.trim().toLowerCase() + if (trimmed === 'restart') { + const confirmed = confirm('Restart? Your progress will be lost.') + if (!confirmed) { + appendLines([{ kind: 'system', text: '(restart cancelled)' }]) + return + } + clearSave() + state = initialStateFor(world) + transcriptEl.innerHTML = '' + renderAll(state.transcript) + saveState(state) + return + } + if (trimmed === 'undo') { + if (lastState) { + state = lastState + lastState = null + appendLines([{ kind: 'system', text: '(undone)' }]) + saveState(state) + } else { + appendLines([{ kind: 'system', text: 'There is no further back.' }]) + } + return + } + if (trimmed === 'quit') { + saveState(state) + window.location.href = '/' + return + } + + // Engine dispatch — wrapped so a thrown error doesn't kill the terminal. + try { + const ctx = buildParserContext(state) + const command = parse(raw, ctx) + lastState = state + const result = dispatch(state, command, world) + state = result.state + appendLines(result.appended) + saveState(state) + transcriptEl.scrollTop = transcriptEl.scrollHeight + } catch (err) { + console.error('[halfstreet] dispatch error', err) + appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }]) + } + }) + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + saveState(state) + window.location.href = '/' + } + }) + + function buildParserContext(s: GameState): ParserContext { + const room = world.rooms[s.location] + const visibleNouns: { id: string; aliases: string[] }[] = [] + if (room) { + for (const id of room.items) { + const it = world.items[id] + if (it) visibleNouns.push({ id, aliases: it.names }) + } + if (room.encounter && s.encounterState[room.encounter]) { + visibleNouns.push({ id: room.encounter, aliases: [room.encounter] }) + } + } + for (const inst of s.inventory) { + const it = world.items[inst.id] + if (it) visibleNouns.push({ id: inst.id, aliases: it.names }) + } + return { + knownItems: Object.keys(world.items), + knownEncounters: Object.keys(world.encounters), + visibleNouns, + inventoryItemIds: s.inventory.map((i) => i.id), + lastNoun: s.lastNoun, + awaitingDisambiguation: s.pendingDisambiguation, + } + } + + function renderAll(lines: TranscriptLine[]): void { + if (!transcriptEl) return + for (const line of lines) { + const el = document.createElement('div') + el.className = line.kind + el.textContent = line.text + transcriptEl.appendChild(el) + } + transcriptEl.scrollTop = transcriptEl.scrollHeight + } + + function appendLines(lines: TranscriptLine[]): void { + renderAll(lines) + } }