2026-05-08 23:45:10 -05:00
|
|
|
import { parse } from '../engine/parser'
|
|
|
|
|
import type { ParserContext } from '../engine/parser'
|
2026-05-10 10:17:42 -05:00
|
|
|
import { dispatch, initialStateFor, getItemsInRoom, getLightStatus, LIGHT_TURNS_MAX } from '../engine/dispatcher'
|
2026-05-08 23:45:10 -05:00
|
|
|
import { saveState, loadState, clearSave } from '../engine/save'
|
|
|
|
|
import { world } from '../world'
|
|
|
|
|
import type { GameState, TranscriptLine } from '../engine/types'
|
2026-05-09 00:54:54 -05:00
|
|
|
import { TRANSCRIPT_CAP } from '../engine/types'
|
2026-05-09 00:18:25 -05:00
|
|
|
import { computeChips } from './chips'
|
|
|
|
|
import { renderChips } from './chip-render'
|
2026-05-08 23:45:10 -05:00
|
|
|
|
|
|
|
|
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
|
|
|
|
|
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
|
2026-05-10 07:00:22 -05:00
|
|
|
const inputDisplayEl = document.querySelector<HTMLSpanElement>('[data-mystery-input-display]')
|
2026-05-10 10:17:42 -05:00
|
|
|
const lightMeterEl = document.querySelector<HTMLDivElement>('[data-mystery-light-meter]')
|
|
|
|
|
const LIGHT_ICON_URL = new URL('../assets/noun-oil-lamp-8301660.svg', import.meta.url).href
|
2026-05-08 23:45:10 -05:00
|
|
|
|
2026-05-09 21:51:12 -05:00
|
|
|
const HELP_TEXT = `You arrive at the address, but you do not remember what has happened. The road behind you is gone...
|
|
|
|
|
|
|
|
|
|
This is a text adventure. Type short commands to act in the house.
|
|
|
|
|
|
|
|
|
|
Common commands:
|
|
|
|
|
look describe the room again
|
|
|
|
|
n, s, e, w, u, d move by direction
|
|
|
|
|
take lamp pick something up
|
|
|
|
|
examine letter inspect something nearby or held
|
|
|
|
|
read letter read a readable object
|
|
|
|
|
inventory see what you carry
|
|
|
|
|
light lamp with matches use one thing with another
|
|
|
|
|
wait let the room continue
|
|
|
|
|
undo step back once
|
|
|
|
|
restart begin again
|
|
|
|
|
theme change the terminal colors
|
|
|
|
|
|
|
|
|
|
Most commands are verb first, then the thing: examine gate, take lamp, use key on door.`
|
|
|
|
|
|
2026-05-10 07:00:22 -05:00
|
|
|
if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
2026-05-08 23:45:10 -05:00
|
|
|
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
|
2026-05-09 21:51:12 -05:00
|
|
|
let transientHelpEl: HTMLDivElement | null = null
|
2026-05-10 07:00:22 -05:00
|
|
|
let commandHistory: string[] = []
|
|
|
|
|
let historyIndex: number | null = null
|
|
|
|
|
let historyDraft = ''
|
|
|
|
|
let idleHintTimer: number | null = null
|
2026-05-08 23:45:10 -05:00
|
|
|
|
2026-05-10 10:17:42 -05:00
|
|
|
const syncLightMeter = (): void => {
|
|
|
|
|
if (!lightMeterEl) return
|
|
|
|
|
const status = getLightStatus(state, world)
|
|
|
|
|
lightMeterEl.hidden = !status
|
|
|
|
|
if (!status) {
|
|
|
|
|
lightMeterEl.innerHTML = ''
|
|
|
|
|
lightMeterEl.dataset['lit'] = 'false'
|
|
|
|
|
lightMeterEl.dataset['turnsLeft'] = '0'
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lightMeterEl.innerHTML = ''
|
|
|
|
|
lightMeterEl.dataset['lit'] = 'true'
|
|
|
|
|
lightMeterEl.dataset['turnsLeft'] = String(status.turnsLeft)
|
|
|
|
|
|
|
|
|
|
const icon = document.createElement('img')
|
|
|
|
|
icon.className = 'mystery-light-icon'
|
|
|
|
|
icon.src = LIGHT_ICON_URL
|
|
|
|
|
icon.alt = ''
|
|
|
|
|
icon.setAttribute('aria-hidden', 'true')
|
|
|
|
|
lightMeterEl.appendChild(icon)
|
|
|
|
|
|
|
|
|
|
const leds = document.createElement('div')
|
|
|
|
|
leds.className = 'mystery-light-leds'
|
|
|
|
|
const turnsLeft = Math.max(0, Math.min(LIGHT_TURNS_MAX, status.turnsLeft))
|
|
|
|
|
for (let i = 0; i < LIGHT_TURNS_MAX; i++) {
|
|
|
|
|
const segment = document.createElement('span')
|
|
|
|
|
segment.className = 'mystery-light-segment'
|
|
|
|
|
segment.dataset['segmentState'] = i < turnsLeft ? 'lit' : 'dim'
|
|
|
|
|
leds.appendChild(segment)
|
|
|
|
|
}
|
|
|
|
|
lightMeterEl.appendChild(leds)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 23:45:10 -05:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 00:18:25 -05:00
|
|
|
function refreshChips(): void {
|
|
|
|
|
renderChips(computeChips(state, world), (command) => {
|
2026-05-10 07:00:22 -05:00
|
|
|
clearIdleHint()
|
2026-05-09 00:18:25 -05:00
|
|
|
inputEl!.value = command
|
2026-05-10 07:00:22 -05:00
|
|
|
syncCommandLine()
|
2026-05-10 05:53:32 -05:00
|
|
|
if (command.endsWith(' ')) {
|
|
|
|
|
inputEl!.focus()
|
|
|
|
|
inputEl!.setSelectionRange(command.length, command.length)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-09 00:18:25 -05:00
|
|
|
inputEl!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 14:57:52 -05:00
|
|
|
const syncEndedUI = (): void => {
|
2026-05-09 15:06:37 -05:00
|
|
|
// Don't disable the input — the player still needs to type `restart` or
|
|
|
|
|
// `undo`. A `disabled` input rejects keydown events entirely. Use a class
|
|
|
|
|
// for visual styling instead; the keydown handler enforces the input
|
|
|
|
|
// restriction.
|
|
|
|
|
inputEl!.classList.toggle('ended', state.endedWith !== null)
|
2026-05-09 14:57:52 -05:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 07:00:22 -05:00
|
|
|
const syncCommandLine = (): void => {
|
|
|
|
|
const visibleText = inputEl.value || inputEl.placeholder
|
|
|
|
|
inputDisplayEl.textContent = visibleText
|
|
|
|
|
inputDisplayEl.dataset['placeholder'] = inputEl.value ? 'false' : inputEl.placeholder ? 'true' : 'false'
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 00:14:15 -05:00
|
|
|
const buildParserContext = (s: GameState): ParserContext => {
|
|
|
|
|
const room = world.rooms[s.location]
|
|
|
|
|
const visibleNouns: { id: string; aliases: string[] }[] = []
|
|
|
|
|
if (room) {
|
2026-05-09 01:03:47 -05:00
|
|
|
for (const id of getItemsInRoom(s, world, s.location)) {
|
2026-05-09 00:14:15 -05:00
|
|
|
const it = world.items[id]
|
|
|
|
|
if (it) visibleNouns.push({ id, aliases: it.names })
|
|
|
|
|
}
|
|
|
|
|
if (room.encounter && s.encounterState[room.encounter]) {
|
2026-05-09 21:51:12 -05:00
|
|
|
const encounter = world.encounters[room.encounter]
|
|
|
|
|
visibleNouns.push({
|
|
|
|
|
id: room.encounter,
|
|
|
|
|
aliases: [room.encounter, room.encounter.replace(/-/g, ' '), ...(encounter?.aliases ?? [])],
|
|
|
|
|
})
|
2026-05-09 00:14:15 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const renderAll = (lines: TranscriptLine[]): void => {
|
|
|
|
|
if (!transcriptEl) return
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
const el = document.createElement('div')
|
|
|
|
|
el.className = line.kind
|
2026-05-10 05:53:32 -05:00
|
|
|
if (line.kind === 'system' && line.text.includes('|_| |_|')) {
|
|
|
|
|
el.classList.add('ascii-art')
|
|
|
|
|
}
|
2026-05-09 00:14:15 -05:00
|
|
|
el.textContent = line.text
|
|
|
|
|
transcriptEl.appendChild(el)
|
|
|
|
|
}
|
|
|
|
|
transcriptEl.scrollTop = transcriptEl.scrollHeight
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 07:00:22 -05:00
|
|
|
const clearIdleHint = (): void => {
|
|
|
|
|
if (idleHintTimer !== null) {
|
|
|
|
|
window.clearTimeout(idleHintTimer)
|
|
|
|
|
idleHintTimer = null
|
|
|
|
|
}
|
|
|
|
|
inputEl.placeholder = ''
|
|
|
|
|
syncCommandLine()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const scheduleIdleHint = (): void => {
|
|
|
|
|
clearIdleHint()
|
|
|
|
|
idleHintTimer = window.setTimeout(() => {
|
|
|
|
|
inputEl.placeholder = 'type here...'
|
|
|
|
|
syncCommandLine()
|
|
|
|
|
}, 30000)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 21:51:12 -05:00
|
|
|
const clearTransientHelp = (): void => {
|
|
|
|
|
transientHelpEl?.remove()
|
|
|
|
|
transientHelpEl = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const renderTransientHelp = (): void => {
|
|
|
|
|
if (!transcriptEl) return
|
|
|
|
|
clearTransientHelp()
|
|
|
|
|
const el = document.createElement('div')
|
|
|
|
|
el.className = 'system help'
|
2026-05-10 10:17:42 -05:00
|
|
|
el.dataset['transientHelp'] = 'true'
|
|
|
|
|
|
|
|
|
|
const close = document.createElement('button')
|
|
|
|
|
close.type = 'button'
|
|
|
|
|
close.className = 'mystery-help-close'
|
|
|
|
|
close.dataset['helpClose'] = 'true'
|
|
|
|
|
close.setAttribute('aria-label', 'Close help')
|
|
|
|
|
close.textContent = 'x'
|
|
|
|
|
close.addEventListener('click', (e) => {
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
clearTransientHelp()
|
|
|
|
|
return
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const text = document.createElement('div')
|
|
|
|
|
text.className = 'mystery-help-body'
|
|
|
|
|
text.textContent = HELP_TEXT
|
|
|
|
|
el.append(close, text)
|
2026-05-09 21:51:12 -05:00
|
|
|
transcriptEl.appendChild(el)
|
|
|
|
|
transientHelpEl = el
|
|
|
|
|
transcriptEl.scrollTop = transcriptEl.scrollHeight
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 10:17:42 -05:00
|
|
|
document.addEventListener('pointerdown', (e) => {
|
|
|
|
|
if (!transientHelpEl) return
|
|
|
|
|
const target = e.target as Node | null
|
|
|
|
|
if (target && transientHelpEl.contains(target)) return
|
|
|
|
|
clearTransientHelp()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const hideHelpOnInput = (): void => {
|
|
|
|
|
if (!transientHelpEl) return
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
if (inputEl.value.trim().length > 0) clearTransientHelp()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 00:54:54 -05:00
|
|
|
// For UI-originated lines (player input, restart/undo/quit messages, error
|
|
|
|
|
// 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.
|
2026-05-09 00:14:15 -05:00
|
|
|
const appendLines = (lines: TranscriptLine[]): void => {
|
2026-05-09 00:54:54 -05:00
|
|
|
state = { ...state, transcript: [...state.transcript, ...lines].slice(-TRANSCRIPT_CAP) }
|
2026-05-09 00:14:15 -05:00
|
|
|
renderAll(lines)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 07:00:22 -05:00
|
|
|
const restart = (): void => {
|
|
|
|
|
const confirmed = confirm('Restart? Your progress will be lost.')
|
|
|
|
|
if (!confirmed) {
|
|
|
|
|
appendLines([{ kind: 'system', text: '(restart cancelled)' }])
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
clearSave()
|
|
|
|
|
state = initialStateFor(world)
|
|
|
|
|
transcriptEl.innerHTML = ''
|
|
|
|
|
inputEl.value = ''
|
|
|
|
|
syncCommandLine()
|
|
|
|
|
renderAll(state.transcript)
|
|
|
|
|
saveState(state)
|
|
|
|
|
refreshChips()
|
2026-05-10 10:17:42 -05:00
|
|
|
syncLightMeter()
|
2026-05-10 07:00:22 -05:00
|
|
|
syncEndedUI()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 23:45:10 -05:00
|
|
|
renderAll(state.transcript)
|
2026-05-09 00:18:25 -05:00
|
|
|
refreshChips()
|
2026-05-10 10:17:42 -05:00
|
|
|
syncLightMeter()
|
2026-05-09 14:57:52 -05:00
|
|
|
syncEndedUI()
|
2026-05-10 07:00:22 -05:00
|
|
|
syncCommandLine()
|
|
|
|
|
scheduleIdleHint()
|
2026-05-08 23:45:10 -05:00
|
|
|
|
|
|
|
|
inputEl.addEventListener('keydown', (e) => {
|
2026-05-10 07:00:22 -05:00
|
|
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
|
|
|
if (commandHistory.length === 0) return
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
if (historyIndex === null) {
|
|
|
|
|
historyDraft = inputEl.value
|
|
|
|
|
historyIndex = commandHistory.length
|
|
|
|
|
}
|
|
|
|
|
if (e.key === 'ArrowUp') {
|
|
|
|
|
historyIndex = Math.max(0, historyIndex - 1)
|
|
|
|
|
} else {
|
|
|
|
|
historyIndex = Math.min(commandHistory.length, historyIndex + 1)
|
|
|
|
|
}
|
|
|
|
|
inputEl.value = historyIndex === commandHistory.length ? historyDraft : commandHistory[historyIndex]!
|
|
|
|
|
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length)
|
|
|
|
|
syncCommandLine()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-08 23:45:10 -05:00
|
|
|
if (e.key !== 'Enter') return
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
const raw = inputEl.value
|
|
|
|
|
inputEl.value = ''
|
2026-05-10 07:00:22 -05:00
|
|
|
syncCommandLine()
|
2026-05-08 23:45:10 -05:00
|
|
|
if (!raw.trim()) return
|
2026-05-09 21:51:12 -05:00
|
|
|
clearTransientHelp()
|
2026-05-10 07:00:22 -05:00
|
|
|
clearIdleHint()
|
|
|
|
|
commandHistory = [...commandHistory, raw].slice(-50)
|
|
|
|
|
historyIndex = null
|
|
|
|
|
historyDraft = ''
|
2026-05-08 23:45:10 -05:00
|
|
|
appendLines([{ kind: 'player', text: raw }])
|
|
|
|
|
|
2026-05-09 14:57:52 -05:00
|
|
|
// 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`.' }])
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 23:45:10 -05:00
|
|
|
// Engine-level meta-commands handled here so the engine stays pure.
|
|
|
|
|
const trimmed = raw.trim().toLowerCase()
|
|
|
|
|
if (trimmed === 'restart') {
|
2026-05-10 07:00:22 -05:00
|
|
|
restart()
|
2026-05-08 23:45:10 -05:00
|
|
|
return
|
|
|
|
|
}
|
2026-05-09 21:51:12 -05:00
|
|
|
if (trimmed === 'help') {
|
|
|
|
|
renderTransientHelp()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-08 23:45:10 -05:00
|
|
|
if (trimmed === 'undo') {
|
|
|
|
|
if (lastState) {
|
|
|
|
|
state = lastState
|
|
|
|
|
lastState = null
|
|
|
|
|
appendLines([{ kind: 'system', text: '(undone)' }])
|
|
|
|
|
saveState(state)
|
2026-05-09 00:18:25 -05:00
|
|
|
refreshChips()
|
2026-05-10 10:17:42 -05:00
|
|
|
syncLightMeter()
|
2026-05-09 14:57:52 -05:00
|
|
|
syncEndedUI()
|
2026-05-08 23:45:10 -05:00
|
|
|
} 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
|
2026-05-09 00:54:54 -05:00
|
|
|
renderAll(result.appended) // dispatch already pushed these into state.transcript
|
2026-05-08 23:45:10 -05:00
|
|
|
saveState(state)
|
|
|
|
|
transcriptEl.scrollTop = transcriptEl.scrollHeight
|
2026-05-09 00:14:15 -05:00
|
|
|
if (raw.trim().toLowerCase() === 'theme') {
|
|
|
|
|
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
|
|
|
|
|
}
|
2026-05-09 00:18:25 -05:00
|
|
|
refreshChips()
|
2026-05-10 10:17:42 -05:00
|
|
|
syncLightMeter()
|
2026-05-09 14:57:52 -05:00
|
|
|
syncEndedUI()
|
2026-05-08 23:45:10 -05:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[halfstreet] dispatch error', err)
|
|
|
|
|
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }])
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-10 07:00:22 -05:00
|
|
|
inputEl.addEventListener('input', syncCommandLine)
|
|
|
|
|
inputEl.addEventListener('focus', clearIdleHint)
|
|
|
|
|
inputEl.addEventListener('pointerdown', clearIdleHint)
|
|
|
|
|
|
|
|
|
|
inputEl.parentElement?.addEventListener('pointerdown', () => {
|
|
|
|
|
inputEl.focus()
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-08 23:45:10 -05:00
|
|
|
document.addEventListener('keydown', (e) => {
|
2026-05-10 07:00:22 -05:00
|
|
|
const target = e.target as HTMLElement | null
|
|
|
|
|
const isEditable =
|
|
|
|
|
target instanceof HTMLInputElement ||
|
|
|
|
|
target instanceof HTMLTextAreaElement ||
|
|
|
|
|
target?.isContentEditable === true
|
|
|
|
|
if (e.key === '/' && !isEditable) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
clearIdleHint()
|
|
|
|
|
inputEl.focus()
|
|
|
|
|
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-08 23:45:10 -05:00
|
|
|
if (e.key === 'Escape') {
|
2026-05-10 10:17:42 -05:00
|
|
|
if (transientHelpEl) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
clearTransientHelp()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-08 23:45:10 -05:00
|
|
|
saveState(state)
|
|
|
|
|
window.location.href = '/'
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-05-10 07:00:22 -05:00
|
|
|
|
|
|
|
|
document.addEventListener('halfstreet-restart', restart)
|
2026-05-10 10:17:42 -05:00
|
|
|
inputEl.addEventListener('input', hideHelpOnInput)
|
2026-05-08 23:39:58 -05:00
|
|
|
}
|