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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<HTMLDivElement>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<Direction, string> = {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { dispatch, initialStateFor } from '../engine/dispatcher'
|
|||||||
import { saveState, loadState, clearSave } from '../engine/save'
|
import { saveState, loadState, clearSave } from '../engine/save'
|
||||||
import { world } from '../world'
|
import { world } from '../world'
|
||||||
import type { GameState, TranscriptLine } from '../engine/types'
|
import type { GameState, TranscriptLine } from '../engine/types'
|
||||||
|
import { computeChips } from './chips'
|
||||||
|
import { renderChips } from './chip-render'
|
||||||
|
|
||||||
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
|
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
|
||||||
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
|
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
|
||||||
@@ -23,6 +25,13 @@ if (!transcriptEl || !inputEl) {
|
|||||||
state = initialStateFor(world)
|
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 buildParserContext = (s: GameState): ParserContext => {
|
||||||
const room = world.rooms[s.location]
|
const room = world.rooms[s.location]
|
||||||
const visibleNouns: { id: string; aliases: string[] }[] = []
|
const visibleNouns: { id: string; aliases: string[] }[] = []
|
||||||
@@ -65,6 +74,7 @@ if (!transcriptEl || !inputEl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderAll(state.transcript)
|
renderAll(state.transcript)
|
||||||
|
refreshChips()
|
||||||
inputEl.focus()
|
inputEl.focus()
|
||||||
|
|
||||||
inputEl.addEventListener('keydown', (e) => {
|
inputEl.addEventListener('keydown', (e) => {
|
||||||
@@ -88,6 +98,7 @@ if (!transcriptEl || !inputEl) {
|
|||||||
transcriptEl.innerHTML = ''
|
transcriptEl.innerHTML = ''
|
||||||
renderAll(state.transcript)
|
renderAll(state.transcript)
|
||||||
saveState(state)
|
saveState(state)
|
||||||
|
refreshChips()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (trimmed === 'undo') {
|
if (trimmed === 'undo') {
|
||||||
@@ -96,6 +107,7 @@ if (!transcriptEl || !inputEl) {
|
|||||||
lastState = null
|
lastState = null
|
||||||
appendLines([{ kind: 'system', text: '(undone)' }])
|
appendLines([{ kind: 'system', text: '(undone)' }])
|
||||||
saveState(state)
|
saveState(state)
|
||||||
|
refreshChips()
|
||||||
} else {
|
} else {
|
||||||
appendLines([{ kind: 'system', text: 'There is no further back.' }])
|
appendLines([{ kind: 'system', text: 'There is no further back.' }])
|
||||||
}
|
}
|
||||||
@@ -120,6 +132,7 @@ if (!transcriptEl || !inputEl) {
|
|||||||
if (raw.trim().toLowerCase() === 'theme') {
|
if (raw.trim().toLowerCase() === 'theme') {
|
||||||
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
|
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
|
||||||
}
|
}
|
||||||
|
refreshChips()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[halfstreet] dispatch error', 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. ]' }])
|
||||||
|
|||||||
Reference in New Issue
Block a user