From 9e49f1f51979a06d7b335d9afb788636a2470e7b Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 00:18:25 -0500 Subject: [PATCH] feat(mystery): mobile chip computation and rendering Pure computeChips function (TDD, 4 tests) generates context-aware direction/item/encounter/meta chips from game state; chip-render.ts wires chips to DOM; terminal.ts calls refreshChips on init, each Enter dispatch, restart, and undo. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/chip-render.ts | 19 ++++++++++++ src/ui/chips.test.ts | 36 ++++++++++++++++++++++ src/ui/chips.ts | 71 +++++++++++++++++++++++++++++++++++++++++++ src/ui/terminal.ts | 13 ++++++++ 4 files changed, 139 insertions(+) create mode 100644 src/ui/chip-render.ts create mode 100644 src/ui/chips.test.ts create mode 100644 src/ui/chips.ts diff --git a/src/ui/chip-render.ts b/src/ui/chip-render.ts new file mode 100644 index 0000000..b5e3ca1 --- /dev/null +++ b/src/ui/chip-render.ts @@ -0,0 +1,19 @@ +import type { Chip } from './chips' + +const CHIP_CONTAINER = '[data-mystery-chips]' + +export function renderChips(chips: Chip[], onSelect: (command: string) => void): void { + const container = document.querySelector(CHIP_CONTAINER) + if (!container) return + container.innerHTML = '' + for (const chip of chips) { + const btn = document.createElement('button') + btn.type = 'button' + btn.className = 'mystery-chip' + btn.dataset['chipKind'] = chip.kind + btn.textContent = chip.label + if (chip.disabled) btn.disabled = true + else btn.addEventListener('click', () => onSelect(chip.command)) + container.appendChild(btn) + } +} diff --git a/src/ui/chips.test.ts b/src/ui/chips.test.ts new file mode 100644 index 0000000..1c52a0d --- /dev/null +++ b/src/ui/chips.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { computeChips } from './chips' +import { world } from '../world' +import { initialStateFor } from '../engine/dispatcher' +import { dispatch } from '../engine/dispatcher' + +describe('computeChips — sample world', () => { + it('shows valid exits as direction chips with the dim flag for invalid ones', () => { + const s = initialStateFor(world) + const chips = computeChips(s, world) + const directions = chips.filter((c) => c.kind === 'direction') + expect(directions.find((c) => c.label.includes('N'))?.disabled).toBe(false) + expect(directions.find((c) => c.label.includes('S'))?.disabled).toBe(true) + }) + + it('adds TAKE chips for visible takeable items', () => { + const s = initialStateFor(world) + const chips = computeChips(s, world) + expect(chips.find((c) => c.kind === 'item' && c.command === 'take letter')).toBeTruthy() + }) + + it('adds an encounter verb chip when an encounter is active', () => { + let s = initialStateFor(world) + s = dispatch(s, { kind: 'go', direction: 'n' }, world).state + s = dispatch(s, { kind: 'go', direction: 'e' }, world).state + const chips = computeChips(s, world) + expect(chips.find((c) => c.kind === 'encounter' && c.command.includes('rat'))).toBeTruthy() + }) + + it('always includes LOOK and INV', () => { + const s = initialStateFor(world) + const chips = computeChips(s, world) + expect(chips.find((c) => c.command === 'look')).toBeTruthy() + expect(chips.find((c) => c.command === 'inventory')).toBeTruthy() + }) +}) diff --git a/src/ui/chips.ts b/src/ui/chips.ts new file mode 100644 index 0000000..d23cf0c --- /dev/null +++ b/src/ui/chips.ts @@ -0,0 +1,71 @@ +import type { World } from '../world/types' +import type { GameState, Direction } from '../engine/types' + +export type ChipKind = 'direction' | 'item' | 'encounter' | 'meta' + +export interface Chip { + kind: ChipKind + label: string + command: string // the literal string to inject as input + disabled: boolean +} + +const DIRECTION_LABELS: Record = { + n: '↑ N', s: '↓ S', e: '→ E', w: '← W', u: '↑ U', d: '↓ D', +} + +export function computeChips(state: GameState, world: World): Chip[] { + const out: Chip[] = [] + const room = world.rooms[state.location] + if (!room) return out + + // Direction chips: enabled if exit exists, dimmed otherwise. + const dirs: Direction[] = ['n', 's', 'e', 'w', 'u', 'd'] + for (const d of dirs) { + const present = !!room.exits[d] + if (present || ['n', 's', 'e', 'w'].includes(d)) { + out.push({ + kind: 'direction', + label: DIRECTION_LABELS[d], + command: d, + disabled: !present, + }) + } + } + + // Item chips: TAKE for visible items. + for (const itemId of room.items) { + const item = world.items[itemId] + if (!item || !item.takeable) continue + out.push({ + kind: 'item', + label: `TAKE ${item.names[0]?.toUpperCase() ?? itemId.toUpperCase()}`, + command: `take ${item.names[0] ?? itemId}`, + disabled: false, + }) + } + + // Encounter chips: surface the verbs from the current phase as suggestions. + if (room.encounter && state.encounterState[room.encounter]) { + const def = world.encounters[room.encounter] + const phase = def?.phases[state.encounterState[room.encounter]!] + if (def && phase) { + for (const t of phase.transitions) { + const targetLabel = t.target && t.target !== '*' ? ` ${t.target.toUpperCase()}` : '' + const command = t.target && t.target !== '*' ? `${t.verb} ${t.target}` : t.verb + out.push({ + kind: 'encounter', + label: `${t.verb.toUpperCase()}${targetLabel}`, + command, + disabled: false, + }) + } + } + } + + // Persistent meta chips. + out.push({ kind: 'meta', label: 'LOOK', command: 'look', disabled: false }) + out.push({ kind: 'meta', label: 'INV', command: 'inventory', disabled: false }) + + return out +} diff --git a/src/ui/terminal.ts b/src/ui/terminal.ts index b10a30c..4842142 100644 --- a/src/ui/terminal.ts +++ b/src/ui/terminal.ts @@ -4,6 +4,8 @@ import { dispatch, initialStateFor } from '../engine/dispatcher' import { saveState, loadState, clearSave } from '../engine/save' import { world } from '../world' import type { GameState, TranscriptLine } from '../engine/types' +import { computeChips } from './chips' +import { renderChips } from './chip-render' const transcriptEl = document.querySelector('[data-mystery-transcript]') const inputEl = document.querySelector('[data-mystery-input]') @@ -23,6 +25,13 @@ if (!transcriptEl || !inputEl) { state = initialStateFor(world) } + function refreshChips(): void { + renderChips(computeChips(state, world), (command) => { + inputEl!.value = command + inputEl!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) + }) + } + const buildParserContext = (s: GameState): ParserContext => { const room = world.rooms[s.location] const visibleNouns: { id: string; aliases: string[] }[] = [] @@ -65,6 +74,7 @@ if (!transcriptEl || !inputEl) { } renderAll(state.transcript) + refreshChips() inputEl.focus() inputEl.addEventListener('keydown', (e) => { @@ -88,6 +98,7 @@ if (!transcriptEl || !inputEl) { transcriptEl.innerHTML = '' renderAll(state.transcript) saveState(state) + refreshChips() return } if (trimmed === 'undo') { @@ -96,6 +107,7 @@ if (!transcriptEl || !inputEl) { lastState = null appendLines([{ kind: 'system', text: '(undone)' }]) saveState(state) + refreshChips() } else { appendLines([{ kind: 'system', text: 'There is no further back.' }]) } @@ -120,6 +132,7 @@ if (!transcriptEl || !inputEl) { if (raw.trim().toLowerCase() === 'theme') { document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme')) } + refreshChips() } catch (err) { console.error('[halfstreet] dispatch error', err) appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }])