SLOPSHOPPER

vome-doom

house.wad in a Claude Code pane: your Home Assistant home as a Doom level, played in the terminal. Shoot a lamp and it turns off for real.

newpanecommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vome-doom
│ ┃ house.wad ✕ › fix the failing auth test and add an audit log call │ ┃ house.wad │ ┃ ⏺ Read(src/auth.ts) │ ┃ Your home as a Doom level. Rooms are your ⎿ Read 6 lines │ ┃ areas; lamps, plugs and screens are where ⏺ Update(src/auth.ts) │ ┃ they really are. Shoot a lamp and it turns ⎿ Added 2 lines, removed 1 line │ ┃ off for real. Use a door and its lock opens, ⏺ Bash(bun test) │ ┃ after a Y/N. House problems are the ⎿ 3 pass, 1 fail │ ┃ monsters. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Play for real ] [ Practice (nothing change │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Needs Node 18 or newer. The first game │ ┃ downloads its data once (about 19 MB, › /doom │ ┃ Freedoom's free game data). Built from ⎿ vome-doom: house.wad is open in the pane. Press Play when you ar │ ┃ github.com/Vortitron/housewad. The dashboard │ ┃ button asks Claude to add house.wad's card │ ┃ to your Home Assistant, so you can play it │ ┃ in a browser too. │ ┃ │ ┃ Played through Vome, which also gives Home │ ┃ Assistant remote access without port │ ┃ forwarding, a health score and keys for │ ┃ coding agents: vome.io │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · house.wad
house.wad Your home as a Doom level. Rooms are your areas; lamps, plugs and screens are where they really are. Shoot a lamp and it turns off for real. Use a door and its lock opens, after a Y/N. House problems are the monsters. [ Play for real ] [ Practice (nothing changes) ] [ Put it on Needs Node 18 or newer. The first game downloads its data once (about 19 MB, Freedoom's free game data). Built from github.com/Vortitron/housewad. The dashboard button asks Claude to add house.wad's card to your Home Assistant, so you can play it in a browser too. Played through Vome, which also gives Home Assistant remote access without port forwarding, a health score and keys for coding agents: vome.io
README

vome-doom: house.wad in a Claude Code pane

Your Home Assistant home as a Doom level, played in the terminal beside Claude. The rooms are your areas, and lamps, plugs and screens are where they really are. Shoot a lamp and it turns off for real. Use a door and its lock opens, after a Y/N. House problems are the monsters: a lamp left burning, a plug on standby, a window open while the heating runs.

It is house.wad, the Lovelace card, run by Node instead of a browser and drawn in coloured half blocks.

Quickstart

1. Add the Vome app to Home Assistant, then get a key from it. No sign-up needed.

Add the Vome app to your Home Assistant.

Open Vome and create a key on its Agent tab.

2. In Claude Code, in a terminal:

/plugin install vome-connect --marketplace Vortitron/home-assistant-mcp
/plugin install vome-doom --marketplace Vortitron/home-assistant-mcp
/reload-plugins

3. Play: /doom, then Practice (nothing in the house changes) or Play for real. /doom practice and /doom play start one straight away. Click the picture to give the pane the keyboard.

4. On your dashboard too: the pane's Put it on my Home Assistant dashboard button (or /doom install) asks Claude to add house.wad's card to your Home Assistant, through HACS if you have it, so the same game plays in a browser. Claude asks which dashboard first and changes nothing else.

It needs Node 18 or newer on the computer. The first game downloads its data once, about 19 MB (house.wad's engine and Freedoom's free game data), into ~/.cache/vome-doom.

Keys

Click the picture and these work, as long as the pane has the keyboard:

W / Sforward, back
A / Dturn left, right
Z / Xstrafe left, right
Ffire
Euse (doors, switches)
Y / Nanswer the door's question
Mthe menu
1 to 7weapons

For the full keyboard (arrows, Space to fire), click the line under the picture instead. Esc gives the keyboard back to Claude Code. A terminal reports key presses but not releases, so a press holds the key for a moment, and holding it down keeps you moving. The pane counts the keys it has sent, which shows at a glance whether it is hearing you.

Auto mode

Starting a game reads the home in the background: ha_list_areas, ha_list_devices, ha_get_entity_registry, ha_list_entities and ha_get_state, all read-only. In auto mode Claude Code refuses background calls nobody asked for unless they are allowed by name; when it does, the pane shows the exact lines for your Vome server, a link to your settings.json, a Copy button and Retry. With vome-connect they are:

"mcp__plugin_vome-connect_vome__ha_list_areas",
"mcp__plugin_vome-connect_vome__ha_list_devices",
"mcp__plugin_vome-connect_vome__ha_get_entity_registry",
"mcp__plugin_vome-connect_vome__ha_list_entities",
"mcp__plugin_vome-connect_vome__ha_get_state"

Playing for real calls ha_call_service when you shoot a lamp or open a door. Allowing that by name would let Claude change things without asking too, so the pane leaves that choice to you: practise, play with auto mode off, or allow it if you are happy to.

What it can change

What house.wad's card can, by its own rules: lights, switches, media players and vacuums. Switches whose names look important (a NAS, a router, a server rack) are left alone, and a lock or door asks Y/N first. Practice changes nothing.

Credits

house.wad is GPL-2.0-or-later, built on doomgeneric. Freedoom is BSD-licensed. This plugin downloads them; it does not include them.

Source 3 files
hooks/register.tsx 541 lines
1// house.wad in a pane: the Home Assistant home as a Doom level, in the terminal.
2//
3// house.wad's own game (github.com/Vortitron/housewad) runs as a Node process:
4// its terminal host builds the level from a snapshot of the home, runs the
5// same engine as the Lovelace card, and sends frames as half-block cells,
6// which this pane draws in a Raster. Keys come from a Client strip under the
7// screen and reach the game as small files in a folder (a process started
8// from a plugin has no open stdin). What the game does to the house (a lamp
9// shot, a door used) arrives as a call, which this pane makes through the
10// Vome MCP, as the dashboard pane's buttons do.
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register } from 'claude-code'
13
14import type { Phase, Status } from '../types'
15
16const PANE = 'house-wad'
17/** The house.wad release whose dist/ has the terminal host. */
18const HOUSEWAD_VERSION = '0.8.1'
19const RELEASE = `https://raw.githubusercontent.com/Vortitron/housewad/v${HOUSEWAD_VERSION}/dist/`
20const FILES = ['housewad-term.mjs', 'housewad-engine.js', 'housewad-engine.wasm', 'housewad-zdbsp.js', 'housewad-zdbsp.wasm', 'freedoom2.wad']
21/** What the level is built from: the domains house.wad reads. */
22/** The reads a game starts with, all read-only: what auto mode needs allowed by name. */
23const READS = ['ha_list_areas', 'ha_list_devices', 'ha_get_entity_registry', 'ha_list_entities', 'ha_get_state']
24const DOMAINS = ['light', 'switch', 'lock', 'vacuum', 'sensor', 'media_player', 'cover', 'person', 'sun', 'camera', 'binary_sensor', 'water_heater', 'climate', 'alarm_control_panel']
25
26const phase = atom({ plugin: 'vome-doom', key: 'phase' } as const, 'idle' as Phase)
27const note = atom({ plugin: 'vome-doom', key: 'note' } as const, null as string | null)
28const size = atom({ plugin: 'vome-doom', key: 'size' } as const, null as { columns: number; rows: number } | null)
29const status = atom({ plugin: 'vome-doom', key: 'status' } as const, null as Status | null)
30const homeName = atom({ plugin: 'vome-doom', key: 'home' } as const, null as string | null)
31const blocked = atom({ plugin: 'vome-doom', key: 'blocked' } as const, null as { server: string; tool: string } | null)
32
33// The running game: the module's own, gone with a reload (which kills the child too).
34let frame: string | null = null
35let inputDir: string | null = null
36let sequence = 0
37let server: string | null = null
38let room = { columns: 96, rows: 36 }
39let stopRequested = false
40let settingsFile = '~/.claude/settings.json'
41let lastMode: 'real' | 'practice' = 'practice'
42let keysSent = 0
43/** The size last asked of the game, and when: a resize asks again, twice a second at most. */
44let askedSize = { columns: 0, rows: 0 }
45let askedAt = 0
46let isRunning = false
47
48/**
49 * The controls as pane hotkeys: they work once the pane has the keyboard, which any click on it
50 * gives (the picture included), unlike the strip, which must be clicked itself. Hotkeys are
51 * letters and digits only, so arrows and Space stay with the strip. Each sends the game the
52 * terminal key name or character its own keys map from (A and D in the game strafe; here they turn).
53 */
54const CONTROLS: Array<{ hotkey: string; label: string; key: string }> = [
55  { hotkey: 'w', label: 'forward', key: 'up' },
56  { hotkey: 's', label: 'back', key: 'down' },
57  { hotkey: 'a', label: 'turn left', key: 'left' },
58  { hotkey: 'd', label: 'turn right', key: 'right' },
59  { hotkey: 'z', label: 'strafe left', key: 'a' },
60  { hotkey: 'x', label: 'strafe right', key: 'd' },
61  { hotkey: 'f', label: 'fire', key: 'f' },
62  { hotkey: 'e', label: 'use', key: 'e' },
63  { hotkey: 'y', label: 'yes', key: 'y' },
64  { hotkey: 'n', label: 'no', key: 'n' },
65  { hotkey: 'm', label: 'menu', key: 'm' },
66  ...['fist', 'pistol', 'shotgun', 'chaingun', 'rockets', 'plasma', 'BFG'].map((label, i) => ({ hotkey: String(i + 1), label, key: String(i + 1) })),
67]
68
69export const register: Register = on => {
70  on('session.start', async ($, e, next) => {
71    await $.command.register({ name: 'doom', description: 'Play house.wad: your Home Assistant home as a Doom level, in a pane (play, practice)' })
72    const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? '~'}/.claude`
73    settingsFile = `${configDir.replace(/\/+$/, '')}/settings.json`
74    return next(e)
75  })
76
77  // /doom opens the pane; /doom play or /doom practice starts a game straight away.
78  on('command.run', { command: 'doom' }, async ($, e) => {
79    await $.ui.open({ id: PANE, title: 'house.wad', focus: true })
80    const word = e.args.trim().toLowerCase()
81    if (word === 'install') {
82      await $.prompt.submit({ text: installPrompt((await read($, homeName)) ?? null) })
83      return { text: 'Asking Claude to put house.wad on your Home Assistant dashboard.' }
84    }
85    if (word === 'play' || word === 'practice') {
86      void start($, word === 'play' ? 'real' : 'practice')
87      return { text: `Starting house.wad${word === 'practice' ? ' in practice (nothing in the house changes)' : ''}. Click the strip under the screen to play.` }
88    }
89    return { text: 'house.wad is open in the pane. Press Play when you are ready (or /doom play, /doom practice, /doom install).' }
90  })
91
92  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
93    if (e.surface !== 'terminal') {
94      const { Text } = $.ui.resolve(e)
95      return <Text>house.wad plays in the terminal.</Text>
96    }
97    const { Box, Text, Button, Raster, Client, Markdown, Code } = $.ui.resolve(e)
98    // Room for the screen: the pane's whole width, its height less the strip, the controls and Stop.
99    const bodyRows = e.props.scroll?.bodyRows ?? (e.viewport?.rows ?? 40) - 2
100    room = { columns: Math.max(40, e.props.bodyColumns), rows: Math.max(12, bodyRows - 6) }
101    if (isRunning && (room.columns !== askedSize.columns || Math.abs(room.rows - askedSize.rows) >= 2) && Date.now() - askedAt > 500) {
102      askedSize = { ...room }
103      askedAt = Date.now()
104      void send($, [{ t: 'size', columns: room.columns, rows: room.rows }])
105    }
106    const now = await read($, phase)
107    const said = await read($, note)
108    const home = await read($, homeName)
109    const refusal = await read($, blocked)
110    const rules = refusal
111      ? refusal.tool === 'ha_call_service'
112        ? `"mcp__${refusal.server}__ha_call_service"`
113        : READS.map(tool => `"mcp__${refusal.server}__${tool}"`).join(',\n')
114      : ''
115    const help = refusal ? (
116      <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
117        {refusal.tool === 'ha_call_service' ? (
118          <Text color="warning" wrap="wrap">
119            Auto mode refused what the game did to the house: it calls ha_call_service, and allowing that by name would let
120            Claude change things without asking too. Practise, switch auto mode off, or allow it in {settingsFile} if you are
121            happy with that:
122          </Text>
123        ) : (
124          <Text color="warning" wrap="wrap">
125            Auto mode refused reading your home. Add these to permissions.allow in {settingsFile}; they only read:
126          </Text>
127        )}
128        <Markdown text={`[${settingsFile}](file://${settingsFile})`} />
129        <Code source={rules} />
130        <Box flexDirection="row" gap={2}>
131          <Button key="copy-rule" hotkey="c" onPress={press => $.ui.copy({ text: rules, surface: press.surface })}>
132            Copy
133          </Button>
134          {refusal.tool === 'ha_call_service' ? null : (
135            <Button
136              key="retry"
137              hotkey="r"
138              onPress={async () => {
139                await update($, blocked, () => null)
140                await start($, lastMode)
141              }}
142            >
143              Retry
144            </Button>
145          )}
146          <Button key="dismiss-rule" dimColor onPress={() => update($, blocked, () => null)}>
147            Dismiss
148          </Button>
149        </Box>
150      </Box>
151    ) : null
152
153    if (now === 'playing') {
154      const shape = await read($, size)
155      const line = await read($, status)
156      if (!shape) return <Text dimColor>Starting…</Text>
157      const text = line
158        ? [line.room, line.aim ? `aiming at ${line.aim}` : '', line.last].filter(Boolean).join(' · ') || 'LIVE: this is your house'
159        : 'LIVE: this is your house'
160      return (
161        <Box flexDirection="column">
162          <Raster key="screen" columns={shape.columns} rows={shape.rows} cells={frame ?? blank(shape.columns, shape.rows)} />
163          <Client
164            key="keys"
165            module="./keys.tsx"
166            width={shape.columns}
167            props={{ text, hint: 'Click this line for the full keyboard: arrows, Space to fire. Esc gives the keys back.' }}
168          />
169          <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
170            {CONTROLS.map(c => (
171              <Button key={`k-${c.hotkey}`} plain hotkey={c.hotkey} onPress={() => send($, [{ t: 'key', key: c.key }])}>
172                {c.label}
173              </Button>
174            ))}
175          </Box>
176          <Box flexDirection="row" gap={1}>
177            <Button key="stop" onPress={() => stop($)}>Stop</Button>
178            <Text dimColor wrap="truncate-end">
179              {said ? `${said} · ` : ''}Click the picture, then the keys above work. Keys sent: {keysSent}
180            </Text>
181          </Box>
182          {help}
183        </Box>
184      )
185    }
186
187    const busy = now === 'checking' || now === 'downloading' || now === 'reading' || now === 'building'
188    return (
189      <Box flexDirection="column" gap={1}>
190        <Text bold>house.wad{home ? `: ${home}` : ''}</Text>
191        <Text wrap="wrap">
192          Your home as a Doom level. Rooms are your areas; lamps, plugs and screens are where they really are. Shoot a lamp and it
193          turns off for real. Use a door and its lock opens, after a Y/N. House problems are the monsters.
194        </Text>
195        {busy ? <Text color="yellow">{said ?? 'Working…'}</Text> : said && !refusal ? <Text color={now === 'failed' ? 'red' : undefined} wrap="wrap">{said}</Text> : null}
196        {help}
197        {busy ? null : (
198          <Box flexDirection="row" gap={1} flexWrap="wrap">
199            <Button key="play" variant="primary" hotkey="p" onPress={() => start($, 'real')}>Play for real</Button>
200            <Button key="practice" hotkey="t" onPress={() => start($, 'practice')}>Practice (nothing changes)</Button>
201            <Button key="install" hotkey="i" onPress={() => $.prompt.submit({ text: installPrompt(home) })}>
202              Put it on my Home Assistant dashboard
203            </Button>
204          </Box>
205        )}
206        <Text dimColor wrap="wrap">
207          Needs Node 18 or newer. The first game downloads its data once (about 19 MB, Freedoom's free game data). Built from
208          github.com/Vortitron/housewad. The dashboard button asks Claude to add house.wad's card to your Home Assistant, so
209          you can play it in a browser too.
210        </Text>
211        <Text dimColor wrap="wrap">
212          Played through Vome, which also gives Home Assistant remote access without port forwarding, a health score and keys
213          for coding agents: vome.io
214        </Text>
215      </Box>
216    )
217  })
218
219  // Keys from the strip under the screen, to the game.
220  on('ui.message', async ($, e, next) => {
221    if (e.requestId !== PANE || e.element !== 'keys') return next(e)
222    const data = e.data as { keys?: unknown }
223    const keys = Array.isArray(data?.keys) ? data.keys.filter((k): k is string => typeof k === 'string' && k.length > 0) : []
224    if (keys.length > 0) await send($, keys.map(key => ({ t: 'key', key })))
225    return {}
226  })
227}
228
229// ---------------------------------------------------------------- the game
230
231async function start($: EngineInterface, mode: 'real' | 'practice') {
232  lastMode = mode
233  stopRequested = false
234  await update($, blocked, () => null)
235  frame = null
236  await update($, status, () => null)
237  await update($, size, () => null)
238  try {
239    await setPhase($, 'checking', 'Looking for Node…')
240    let why = ''
241    const node = await $.process.run(['node', '--version'], { timeoutMs: 10_000 }).catch(error => {
242      why = firstLine(String(error)) ?? ''
243      return null
244    })
245    const found = node && node.exitCode === 0 ? node.stdout.trim() : ''
246    const major = Number(/^v(\d+)/.exec(found)?.[1] ?? 0)
247    if (major < 18) {
248      return fail($, `house.wad needs Node 18 or newer on this computer (nodejs.org), then press Play again.${found ? ` This one is ${found}.` : why ? ` (${why})` : ''}`)
249    }
250
251    const home = await $.env.get('HOME')
252    if (!home) return fail($, 'There is no HOME folder to keep the game data in.')
253    const dir = `${home}/.cache/vome-doom/${HOUSEWAD_VERSION}`
254    const missing: string[] = []
255    for (const file of FILES) if (!(await $.fs.exists(`${dir}/${file}`))) missing.push(file)
256    if (missing.length > 0 && !(await download($, dir, missing))) return
257
258    server = await findServer($)
259    if (!server) return fail($, 'Connect Home Assistant through Vome first: /plugin install vome-connect --marketplace Vortitron/home-assistant-mcp')
260    await setPhase($, 'reading', 'Reading your home…')
261    const snapshot = await readHome($, server)
262    if (!snapshot) return
263    await update($, homeName, () => snapshot.location_name)
264
265    await setPhase($, 'building', `Building ${snapshot.location_name} as a level…`)
266    inputDir = `${home}/.cache/vome-doom/run-${Date.now()}`
267    await $.fs.write(`${inputDir}/.keep`, '')
268    void run($, dir, mode, snapshot)
269  } catch (error) {
270    await fail($, `Could not start: ${String(error)}`)
271  }
272}
273
274async function run($: EngineInterface, dir: string, mode: 'real' | 'practice', snapshot: Snapshot) {
275  const argv = [
276    'node', `${dir}/housewad-term.mjs`, '--assets', dir, '--input', inputDir!, '--mode', mode,
277    '--columns', String(room.columns), '--rows', String(room.rows),
278  ]
279  let pending = ''
280  let isPlaying = false
281  isRunning = true
282  askedSize = { ...room }
283  askedAt = Date.now()
284  try {
285    for await (const piece of $.process.spawn({ argv, input: JSON.stringify(snapshot) })) {
286      if (stopRequested) break
287      if (piece.stream !== 'stdout') continue
288      pending += piece.text
289      let at = pending.indexOf('\n')
290      while (at >= 0) {
291        const line = pending.slice(0, at)
292        pending = pending.slice(at + 1)
293        at = pending.indexOf('\n')
294        let message: Record<string, unknown>
295        try {
296          message = JSON.parse(line) as Record<string, unknown>
297        } catch {
298          continue
299        }
300        if (message.t === 'frame' && typeof message.cells === 'string') {
301          frame = message.cells
302          const shape = (await read($, size))
303          if (isPlaying && shape) void $.ui.blit({ requestId: PANE, key: 'screen', cells: frame, columns: shape.columns, rows: shape.rows })
304        } else if (message.t === 'ready') {
305          // The first says the game is on; later ones are a new size after the pane changed.
306          frame = null
307          await update($, size, () => ({ columns: Number(message.columns), rows: Number(message.rows) }))
308          if (!isPlaying) await setPhase($, 'playing', mode === 'real' ? null : 'Practice: nothing in the house changes.')
309          isPlaying = true
310        } else if (message.t === 'status') {
311          await update($, status, () => ({ room: String(message.room ?? ''), aim: String(message.aim ?? ''), last: String(message.last ?? ''), world: String(message.world ?? '') }))
312        } else if (message.t === 'call') {
313          void act($, message)
314        } else if (message.t === 'error') {
315          isRunning = false
316          await fail($, String(message.text ?? 'The game stopped.'))
317          return
318        }
319      }
320    }
321  } catch (error) {
322    isRunning = false
323    await fail($, `The game stopped: ${String(error)}`)
324    return
325  }
326  isRunning = false
327  if ((await read($, phase)) !== 'failed') await setPhase($, 'ended', stopRequested ? null : 'The game ended.')
328}
329
330/** A call the game makes on the house: through the MCP, then the entity's new state back to it. */
331async function act($: EngineInterface, message: Record<string, unknown>) {
332  const id = message.id
333  const data = (message.data && typeof message.data === 'object' ? message.data : {}) as Record<string, unknown>
334  let error: string | null = null
335  try {
336    const result = await $.mcp.call(server!, 'ha_call_service', { domain: message.domain, service: message.service, data })
337    if (result.isError) error = firstLine(result.content.map(block => block.text ?? '').join('\n')) ?? 'refused'
338  } catch (thrown) {
339    if (/auto mode|classifier|permission|denied/i.test(String(thrown))) {
340      error = 'auto mode refused it'
341      await update($, blocked, () => ({ server: server!, tool: 'ha_call_service' }))
342    } else {
343      error = firstLine(String(thrown)) ?? 'failed'
344    }
345  }
346  await send($, [{ t: 'reply', id, error }])
347  const entity = typeof data.entity_id === 'string' ? data.entity_id : null
348  if (!entity || error) return
349  // The device answers in its own time: look twice.
350  for (const wait of [700, 2500]) {
351    await $.clock.sleep(wait)
352    const state = await stateOf($, entity)
353    if (state) await send($, [{ t: 'state', entity_id: entity, state }])
354  }
355}
356
357async function stop($: EngineInterface) {
358  stopRequested = true
359  await send($, [{ t: 'quit' }])
360  await setPhase($, 'ended', null)
361}
362
363/** Messages to the game: one small file each batch, read in name order and deleted by the game. */
364async function send($: EngineInterface, messages: unknown[]) {
365  if (!inputDir) return
366  keysSent += messages.filter(m => (m as { t?: string }).t === 'key').length
367  sequence += 1
368  await $.fs.write(`${inputDir}/${String(sequence).padStart(9, '0')}.json`, messages.map(m => JSON.stringify(m)).join('\n') + '\n')
369  // The pane shows how many keys went out: a redraw, folded to ten a second by the engine.
370  if (messages.some(m => (m as { t?: string }).t === 'key')) $.ui.invalidate('ui.render')
371}
372
373async function download($: EngineInterface, dir: string, files: string[]): Promise<boolean> {
374  await setPhase($, 'downloading', 'Downloading the game data (once)…')
375  // Node fetches, so nothing but Node is needed; each file lands whole or not at all.
376  const script = [
377    "const fs=require('fs'),path=require('path');",
378    'const [dir,base,...files]=process.argv.slice(1);',
379    '(async()=>{fs.mkdirSync(dir,{recursive:true});',
380    'for(const f of files){const r=await fetch(base+f);if(!r.ok)throw new Error(f+": HTTP "+r.status);',
381    "const total=Number(r.headers.get('content-length'))||0;const parts=[];let got=0,n=0;",
382    'for await(const c of r.body){parts.push(c);got+=c.length;if(++n%200===0)console.log(JSON.stringify({file:f,got,total}))}',
383    "fs.writeFileSync(path.join(dir,f+'.part'),Buffer.concat(parts));fs.renameSync(path.join(dir,f+'.part'),path.join(dir,f));",
384    'console.log(JSON.stringify({file:f,done:true}))}})().catch(e=>{console.error(e.message);process.exit(1)})',
385  ].join('')
386  let errors = ''
387  try {
388    const fetching = $.process.spawn({ argv: ['node', '-e', script, dir, RELEASE, ...files] })
389    for await (const piece of fetching) {
390      if (piece.stream === 'stderr') {
391        errors += piece.text
392        continue
393      }
394      for (const line of piece.text.split('\n')) {
395        try {
396          const p = JSON.parse(line) as { file?: string; got?: number; total?: number }
397          const total = p.total ?? 0
398          if (p.file && total > 0) await update($, note, () => `Downloading ${p.file}: ${Math.round((p.got ?? 0) / 1e6)} of ${Math.round(total / 1e6)} MB`)
399        } catch {
400          // a partial line
401        }
402      }
403    }
404  } catch (error) {
405    errors += String(error)
406  }
407  for (const file of files) {
408    if (!(await $.fs.exists(`${dir}/${file}`))) {
409      await fail($, `Could not download the game data (${firstLine(errors) ?? file}). Check the connection and press Play again.`)
410      return false
411    }
412  }
413  return true
414}
415
416/** What the dashboard button asks Claude: house.wad's own install steps, done through the Vome MCP. */
417function installPrompt(home: string | null): string {
418  return [
419    `Put the house.wad card on my Home Assistant dashboard${home ? ` (${home})` : ''}, using the Vome MCP.`,
420    '',
421    '1. First check it is not there already: a Lovelace resource or a card of type custom:housewad-card.',
422    '2. If HACS is installed, add https://github.com/Vortitron/housewad to HACS as a custom repository of the Dashboard',
423    '   (plugin) category, download it, and check its housewad-card.js is registered as a JavaScript module resource.',
424    '3. Without HACS, do not copy files into /config/www: tell me the manual steps from the house.wad README instead.',
425    '4. Then ask me which dashboard should get it, offering a new one called house.wad, and add a panel view with',
426    '   `type: custom:housewad-card` there. Change nothing else.',
427    '',
428    'house.wad turns the house into a Doom level: in its own Play mode, shooting a lamp turns it off for real, and a',
429    'lock asks Y/N before it opens. Its card has a Practice mode too.',
430  ].join('\n')
431}
432
433// ---------------------------------------------------------------- the home
434
435type Snapshot = {
436  location_name: string
437  areas: unknown[]
438  devices: unknown[]
439  entities: unknown[]
440  states: unknown[]
441}
442
443async function findServer($: EngineInterface): Promise<string | null> {
444  const names = (await $.tool.list()).map(tool => /^mcp__(.+)__ha_list_areas$/.exec(tool.name)?.[1]).filter((s): s is string => !!s)
445  return names.find(name => /vome/i.test(name)) ?? names[0] ?? null
446}
447
448async function readHome($: EngineInterface, from: string): Promise<Snapshot | null> {
449  let location: string | null = null
450  const ask = async (tool: string, args: Record<string, unknown>) => {
451    try {
452      const result = await $.mcp.call(from, tool, args)
453      const text = result.content.map(block => block.text ?? '').join('\n')
454      location = location ?? /\[vome-instance\][^\n]*home="([^"]+)"/.exec(text)?.[1] ?? null
455      if (result.isError) throw new Error(firstLine(text) ?? `${tool} failed`)
456      return parseJson(text) ?? {}
457    } catch (error) {
458      if (/auto mode|classifier|permission|denied/i.test(String(error))) {
459        await update($, blocked, () => ({ server: from, tool }))
460        throw new Error(`auto mode refused ${tool}.`)
461      }
462      throw error
463    }
464  }
465  try {
466    const areas = ((await ask('ha_list_areas', {})).areas as unknown[]) ?? []
467    const devices = ((await ask('ha_list_devices', {})).devices as unknown[]) ?? []
468    const entities: unknown[] = []
469    const states: unknown[] = []
470    for (const domain of DOMAINS) {
471      await update($, note, () => `Reading your home: ${domain.replace(/_/g, ' ')}…`)
472      entities.push(...(((await ask('ha_get_entity_registry', { domain, include_disabled: false })).entities as unknown[]) ?? []))
473      states.push(...(((await ask('ha_list_entities', { domain, include_attributes: true, limit: 500 })).entities as unknown[]) ?? []))
474    }
475    return { location_name: location ?? 'Home', areas, devices, entities, states }
476  } catch (error) {
477    await fail($, `Could not read your home: ${firstLine(String(error).replace(/^Error: /, ''))}`)
478    return null
479  }
480}
481
482async function stateOf($: EngineInterface, entity: string): Promise<Record<string, unknown> | null> {
483  try {
484    const result = await $.mcp.call(server!, 'ha_get_state', { entity_ids: [entity] })
485    const body = parseJson(result.content.map(block => block.text ?? '').join('\n'))
486    const row = Array.isArray(body?.entities) ? (body!.entities as Record<string, unknown>[])[0] : null
487    const held = row && row.state && typeof row.state === 'object' ? (row.state as Record<string, unknown>) : null
488    return held ? { entity_id: entity, ...held } : null
489  } catch {
490    return null
491  }
492}
493
494// ---------------------------------------------------------------- small things
495
496async function setPhase($: EngineInterface, next: Phase, text: string | null) {
497  await update($, phase, () => next)
498  await update($, note, () => text)
499}
500
501async function fail($: EngineInterface, text: string) {
502  await setPhase($, 'failed', text)
503}
504
505/** A black screen of the right size, until the first frame. */
506function blank(columns: number, rows: number): string {
507  const words = new Uint32Array(columns * rows * 3)
508  for (let i = 0; i < columns * rows; i++) words[i * 3] = 0x2580
509  return base64(new Uint8Array(words.buffer))
510}
511
512function base64(bytes: Uint8Array): string {
513  const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
514  let out = ''
515  for (let i = 0; i < bytes.length; i += 3) {
516    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
517    out += A[(n >> 18) & 63]! + A[(n >> 12) & 63]!
518    out += i + 1 < bytes.length ? A[(n >> 6) & 63]! : '='
519    out += i + 2 < bytes.length ? A[n & 63]! : '='
520  }
521  return out
522}
523
524function firstLine(text: string | null): string | null {
525  return text ? (text.split('\n').find(line => line.trim()) ?? null) : null
526}
527
528/** The JSON body of a vome MCP reply, which ends with a `[vome-instance]` line. */
529function parseJson(raw: string): Record<string, unknown> | null {
530  const text = raw.replace(/\[vome-instance\][^\n]*/g, '')
531  const start = text.search(/[[{]/)
532  const end = Math.max(text.lastIndexOf('}'), text.lastIndexOf(']'))
533  if (start < 0 || end <= start) return null
534  try {
535    const value = JSON.parse(text.slice(start, end + 1)) as unknown
536    return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
537  } catch {
538    return null
539  }
540}
541
hooks/keys.tsx 36 lines
1// The strip under the screen: shows the status line and, once clicked, takes the
2// keyboard for the game. A terminal reports presses only; house.wad holds each
3// key for a moment and the terminal's auto-repeat keeps it held. Keys go to the
4// hooks module in batches, as a Client posts once a frame at most.
5import type { ClientModule } from 'claude-code'
6
7type Props = { text: string; hint: string }
8type Local = { queue: string[]; isFocused: boolean }
9
10const Keys: ClientModule<Props, Local> = (props, surface) => {
11  const { Box, Text } = surface.elements
12  if (surface.state === undefined) {
13    const local: Local = { queue: [], isFocused: false }
14    surface.setState(local)
15    surface.onKey(event => {
16      if (event.ctrl || event.meta) return
17      local.isFocused = true
18      local.queue.push(event.key)
19    })
20    surface.onPointer(event => {
21      if (event.type === 'down') local.isFocused = true
22    })
23    surface.every(30, () => {
24      if (local.queue.length > 0) surface.post({ keys: local.queue.splice(0, local.queue.length) })
25    })
26  }
27  return (
28    <Box flexDirection="column">
29      <Text wrap="truncate-end">{props.text}</Text>
30      <Text dimColor wrap="truncate-end">{props.hint}</Text>
31    </Box>
32  )
33}
34
35export default Keys
36
types/index.d.ts 23 lines
1/** Where a game is: what the pane draws. */
2export type Phase = 'idle' | 'checking' | 'downloading' | 'reading' | 'building' | 'playing' | 'ended' | 'failed'
3
4/** The game's status line, as house.wad's card shows it under the screen. */
5export type Status = { room: string; aim: string; last: string; world: string }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'vome-doom': {
10      phase: Phase
11      /** One line about what is happening or went wrong. */
12      note: string | null
13      /** The screen in cells, once the game has said. */
14      size: { columns: number; rows: number } | null
15      status: Status | null
16      /** Which home: the name the game titles its level with. */
17      home: string | null
18      /** Auto mode refused one of the pane's calls: on which server, and which tool. */
19      blocked: { server: string; tool: string } | null
20    }
21  }
22}
23