OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / distribute / hypertps-win64 / lib / tui.js

1117 lines · javascript ref:
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import subprocess from 'bare-subprocess'
import { Program } from './program.js'
import DaemonClient from './daemon-client.js'
import { handleInputKey } from './input-box.js'
import * as s from './style.js'

// ---- Global Registry (hardcoded) ----
// The public "discovery" registry every hyper-tps user auto-joins on first
// launch. Anyone holding GLOBAL_REGISTRY_INVITE can subscribe (read-only).
// To list new rooms in the global registry, the curator hands out a separate
// WRITE invite. To rotate, ship a new release with a new invite string.
// Bootstrap on tabertron: see docs/bootstrap-global-registry.md.
const GLOBAL_REGISTRY_ROOM_ID = '8yphx5a55cen3cwzt34z5mcoc5bi31agguuaijjjqmnbimr676eo'
const GLOBAL_REGISTRY_INVITE  = 'dnyuog687hp7srbcskmah7m7isegpo4h3cddj3hkwwwzfty4i1xq9rmboacsam5wdxcy8zp4wobfkzooqqojuoxb4db699a1m1wrcgrzjgwqif6hptmtetbgnrebe3mssnbeixda61kkzowfhi4tpsjsa63khymdrhsft8m8uq8p4mwghmsa5o1ikcmci8g397n31wb9mzzkbsostybitto3itghfndqdffwiowagz98qmg4'

// ---- Config ----

const CONFIG_DIR = path.join(os.homedir(), '.config', 'hyper-tps')
const CONFIG_PATH = path.join(CONFIG_DIR, 'ded-peer.json')

function loadConfig () {
  try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) }
  catch { return { home_room: '', home_room_id: '', role: 'admin', auto_join: false } }
}

function saveConfig (cfg) {
  try { fs.mkdirSync(CONFIG_DIR, { recursive: true }) } catch {}
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2))
}

// ---- Model ----

export default async function startTUI (opts) {
  // Whether this TUI was launched in --dedicated mode (no Godot
  // auto-spawned by the launcher). Phase 2 of hyper-fleeky-dev#32 uses
  // this to auto-start Godot from the TUI when we land in a room as
  // admin. Defaults to true since startTUI is only called from the
  // dedicated branch today, but explicit pass-through is cleaner.
  const dedicatedMode = (opts && opts.dedicatedMode !== false)
  const { socketPath, buildDir, gamePath, appVersion } = opts
  const client = new DaemonClient({ socketPath })

  const model = {
    // State
    screen: 'connecting', // 'connecting' | 'room_select' | 'registry' | 'live'
    config: loadConfig(),
    appVersion: appVersion || 'dev',

    // Daemon
    connected: false,
    identity: null,
    rooms: [],

    // Global Registry — directory of public rooms. The TUI surfaces it as
    // a pinned entry on room_select and a dedicated 'registry' screen.
    registryJoined: false,
    registryListings: [],
    registryCursor: 0,
    registryLoading: false,
    registryError: null,

    // Room
    selectedRoom: null,
    members: [],

    // Buffers (weechat-style)
    buffers: [{ name: 'server', label: 'server', lines: [], unread: 0, highlight: false, scroll: 0 }],
    activeBuffer: 0,

    // Input
    inputBuffer: '',
    cursorPos: 0,
    selectInput: '',
    selectMode: null, // null | 'create' | 'join'

    // Game
    gameProcess: null,
    gameLog: [],
    diagData: null,
    diagPath: '/tmp/hypertps-ded.diag.json',

    // Dedup
    _seenIds: new Set(),
    _selfSent: new Set(),

    // ---- Helpers ----

    buf () { return this.buffers[this.activeBuffer] },

    serverBuf () { return this.buffers[0] },

    findOrCreateRoomBuf (roomId, title) {
      let buf = this.buffers.find(b => b.name === roomId)
      if (!buf) {
        buf = { name: roomId, label: '#' + (title || roomId.slice(0, 8)), lines: [], unread: 0, highlight: false, scroll: 0 }
        this.buffers.push(buf)
      }
      return buf
    },

    pushLine (buf, text) {
      const ts = new Date()
      const time = String(ts.getHours()).padStart(2, '0') + ':' + String(ts.getMinutes()).padStart(2, '0') + ':' + String(ts.getSeconds()).padStart(2, '0')
      buf.lines.push({ time, text })
      if (buf.lines.length > 1000) buf.lines.shift()
      const idx = this.buffers.indexOf(buf)
      if (idx !== this.activeBuffer) { buf.unread++; buf.highlight = true }
    },

    srvMsg (text) { this.pushLine(this.serverBuf(), text) },
    srvEvent (text) { this.srvMsg(s.green(text)) },
    srvError (text) { this.srvMsg(s.red(text)) },
    srvInfo (text) { this.srvMsg(s.dim(text)) },

    chatMsg (roomId, sender, text) {
      const buf = this.buffers.find(b => b.name === roomId)
      if (buf) this.pushLine(buf, s.cyan('<' + sender + '>') + ' ' + text)
    },

    isMetadata (text) {
      if (!text) return false
      return text.includes('"_hs"') || text.includes('"room-meta"') ||
        (text.startsWith('{') && text.includes('"discoverable"'))
    },

    // ---- View ----

    view (w, h) {
      switch (this.screen) {
        case 'connecting': return this.viewConnecting(w, h)
        case 'room_select': return this.viewRoomSelect(w, h)
        case 'registry': return this.viewRegistry(w, h)
        case 'live': return this.viewLive(w, h)
      }
      return []
    },

    viewConnecting (w, h) {
      const lines = []
      lines.push(s.bold(s.cyan('HyperTPS Dedicated Peer')) + s.dim(' v' + this.appVersion))
      lines.push('')
      lines.push(s.dim('Connecting to daemon at ' + socketPath + '...'))
      while (lines.length < h) lines.push('')
      return lines
    },

    viewRoomSelect (w, h) {
      const lines = []
      lines.push(s.bold(s.cyan('HyperTPS Dedicated Peer')) + s.dim(' v' + this.appVersion))
      if (this.identity) {
        lines.push('Identity: ' + s.green(this.identity.displayName || this.identity.username || '?'))
      }
      lines.push(s.hline(w))

      // Pinned: Global Registry. If joined we show the entry pointing to the
      // registry browser screen; if not we show a stub that joins on 'g'.
      if (this.registryJoined) {
        lines.push('  ' + s.yellow('★') + ' ' + s.bold('Hyper-Fleeky Global Registry') + s.dim('  [g] browse'))
      } else {
        lines.push('  ' + s.dim('☆ Hyper-Fleeky Global Registry — ') + s.yellow('[g]') + s.dim(' to join'))
      }
      lines.push('')

      lines.push(s.bold('Rooms') + ' (' + this.rooms.length + '):')
      if (this.rooms.length === 0) {
        lines.push(s.dim('  (no rooms)'))
      } else {
        for (let i = 0; i < this.rooms.length && i < 20; i++) {
          const r = this.rooms[i]
          const home = this.config.home_room_id === r.roomId ? s.green(' [HOME]') : ''
          lines.push('  ' + s.cyan(String(i + 1)) + '. ' + r.title + home)
        }
      }
      lines.push('')
      lines.push(s.cyan('[1-N]') + ' Select  ' + s.cyan('[c]') + ' Create  ' + s.cyan('[j]') + ' Join  ' + s.cyan('[g]') + ' Registry  ' + s.cyan('[q]') + ' Quit')
      if (this.selectMode) {
        const prompt = this.selectMode === 'create' ? 'Room name: ' : 'Invite: '
        lines.push('')
        lines.push(prompt + this.selectInput + '\u2588')
      }
      while (lines.length < h) lines.push('')
      return lines
    },

    viewRegistry (w, h) {
      const lines = []
      lines.push(s.bold(s.cyan('Hyper-Fleeky Global Registry')) + s.dim('  — directory of public rooms'))
      lines.push(s.dim('  room: ' + GLOBAL_REGISTRY_ROOM_ID.slice(0, 16) + '…'))
      lines.push(s.hline(w))
      if (this.registryError) {
        lines.push(s.red('Error: ' + this.registryError))
        lines.push('')
      }
      if (this.registryLoading) {
        lines.push(s.dim('  loading listings…'))
      } else if (!this.registryListings.length) {
        lines.push(s.dim('  (no listings yet — registry empty or unreachable)'))
      } else {
        for (let i = 0; i < this.registryListings.length; i++) {
          const l = this.registryListings[i]
          const sel = i === this.registryCursor ? s.green('▶ ') : '  '
          const title = (l.title || '(untitled)').slice(0, 60)
          const topics = (l.topics && l.topics.length) ? s.dim(' [' + l.topics.join(',') + ']') : ''
          lines.push(sel + s.bold(title) + topics)
          if (l.description) lines.push('    ' + s.dim(l.description.slice(0, 80)))
        }
      }
      lines.push('')
      lines.push(s.cyan('[↑↓]') + ' navigate  ' + s.cyan('[enter]') + ' join  ' + s.cyan('[r]') + ' refresh  ' + s.cyan('[a]') + ' list all my rooms  ' + s.cyan('[esc]') + ' back')
      while (lines.length < h) lines.push('')
      return lines
    },

    viewLive (w, h) {
      const buf = this.buf()
      const rightW = 28
      const feedW = Math.max(20, w - rightW - 1)
      const bodyH = h - 3 // buffer bar(1) + separator(1) + input(1)

      const lines = []

      // ---- Buffer bar ----
      let bar = ''
      for (let i = 0; i < this.buffers.length; i++) {
        const b = this.buffers[i]
        const num = i + 1
        if (i === this.activeBuffer) {
          bar += s.bold(s.white(' [' + num + ':' + b.label + '] '))
        } else if (b.highlight) {
          bar += s.yellow(' ' + num + ':' + b.label + ' ')
        } else if (b.unread > 0) {
          bar += s.cyan(' ' + num + ':' + b.label + '(' + b.unread + ') ')
        } else {
          bar += s.brightBlack(' ' + num + ':' + b.label + ' ')
        }
      }
      const online = this.members.filter(m => m.online).length
      const gameStatus = this.gameProcess ? s.green('GAME \u25b6') : s.yellow('STOPPED')
      const status = s.dim(online + ' online  ' + gameStatus)
      const barVis = s.visibleLength(bar)
      const statusVis = s.visibleLength(status)
      const gap = Math.max(1, w - barVis - statusVis)
      lines.push(s.bg256(234, s.padRight(bar + ' '.repeat(gap) + status, w)))

      // ---- Feed + right panel ----
      const feedLines = buf.lines
      const scroll = buf.scroll || 0
      const startIdx = Math.max(0, feedLines.length - bodyH - scroll)
      const endIdx = feedLines.length - scroll

      const rightLines = this.activeBuffer === 0 ? this.rightPanelServer(bodyH) : this.rightPanelChat(bodyH)
      const sep = s.brightBlack(s.vline())

      for (let row = 0; row < bodyH; row++) {
        const li = startIdx + row
        let left = ''
        if (li >= 0 && li < endIdx && li < feedLines.length) {
          const entry = feedLines[li]
          left = s.brightBlack(entry.time) + ' ' + entry.text
        }
        left = s.truncate(s.padRight(left, feedW), feedW)
        const right = s.padRight(rightLines[row] || '', rightW)
        lines.push(left + sep + right)
      }

      // ---- Separator + input ----
      lines.push(s.hline(w))
      const label = s.dim('[' + buf.label + ']')
      const prompt = s.cyan('> ')
      const input = this.inputBuffer || ''
      lines.push(label + ' ' + prompt + s.padRight(input + '\u2588', w - s.visibleLength(label) - 3))

      return lines
    },

    rightPanelServer (height) {
      const lines = []
      lines.push(s.bold('Commands'))
      const cmds = [
        ['/start', 'Start game'], ['/stop', 'Stop game'],
        ['/kick', '<name>'], ['/promote', '<n> <role>'],
        ['/demote', '<name>'], ['/invite', 'Show invite'],
        ['/map', '<name>'], ['/home', 'Set home room'],
        ['/diag', 'Diagnostics'], ['/log', 'Game log'],
        ['/rooms', 'Room select'], ['/quit', 'Exit'],
      ]
      for (const [cmd, desc] of cmds) {
        lines.push(s.cyan(cmd) + ' ' + s.dim(desc))
      }
      lines.push('')
      lines.push(s.bold('Members'))
      for (const m of this.members) {
        const dot = m.online ? s.green('\u25cf') : s.dim('\u25cb')
        const role = m.role === 'admin' ? s.green('(admin)') : m.role === 'mod' ? s.yellow('(mod)') : ''
        lines.push(dot + ' ' + m.name + ' ' + role)
      }
      while (lines.length < height) lines.push('')
      return lines
    },

    rightPanelChat (height) {
      const lines = []
      lines.push(s.bold('Members'))
      for (const m of this.members) {
        const dot = m.online ? s.green('\u25cf') : s.dim('\u25cb')
        const role = m.role === 'admin' ? s.green('(admin)') : m.role === 'mod' ? s.yellow('(mod)') : ''
        lines.push(dot + ' ' + m.name + ' ' + role)
      }
      lines.push('')
      if (this.selectedRoom) {
        lines.push(s.dim('Room: ' + this.selectedRoom.title))
        lines.push(s.dim(this.selectedRoom.roomId.slice(0, 20) + '...'))
      }
      while (lines.length < height) lines.push('')
      return lines
    },

    // ---- Update (MVU) ----

    update (msg) {
      if (msg.type === 'resize') {
        this.width = msg.width
        this.height = msg.height
        return
      }

      if (msg.type === 'key') {
        return this.handleKey(msg.key)
      }
    },

    handleKey (key) {
      // Ctrl+C handled by Program

      // Ctrl+L redraw (no-op, Program re-renders after dispatch)
      if (key.name === 'l' && key.ctrl) return

      // Alt+number: switch buffer (key.name is '1'..'9' when meta)
      if (key.meta && key.name >= '1' && key.name <= '9') {
        const idx = parseInt(key.name) - 1
        if (idx < this.buffers.length) {
          this.activeBuffer = idx
          this.buffers[idx].unread = 0
          this.buffers[idx].highlight = false
          this.buffers[idx].scroll = 0
        }
        return
      }

      // Alt+a: jump to active buffer
      if (key.meta && key.name === 'a') {
        for (let i = 0; i < this.buffers.length; i++) {
          if (this.buffers[i].highlight || this.buffers[i].unread > 0) {
            this.activeBuffer = i
            this.buffers[i].unread = 0
            this.buffers[i].highlight = false
            this.buffers[i].scroll = 0
            return
          }
        }
        return
      }

      // Tab: cycle buffers
      if (key.name === 'tab') {
        const next = (this.activeBuffer + 1) % this.buffers.length
        this.activeBuffer = next
        this.buffers[next].unread = 0
        this.buffers[next].highlight = false
        this.buffers[next].scroll = 0
        return
      }

      // PgUp/PgDn
      if (key.name === 'pageup') {
        this.buf().scroll = Math.min(this.buf().scroll + 10, Math.max(0, this.buf().lines.length - 10))
        return
      }
      if (key.name === 'pagedown') {
        this.buf().scroll = Math.max(0, this.buf().scroll - 10)
        return
      }

      // Route to screen handler
      switch (this.screen) {
        case 'room_select': return this.handleRoomSelectKey(key)
        case 'registry': return this.handleRegistryKey(key)
        case 'live': return this.handleLiveKey(key)
      }
    },

    handleRoomSelectKey (key) {
      if (this.selectMode) {
        if (key.name === 'escape') {
          this.selectMode = null
          this.selectInput = ''
          return
        }
        if (key.name === 'return') {
          const text = this.selectInput.trim()
          const mode = this.selectMode
          this.selectInput = ''
          this.selectMode = null
          if (text) {
            if (mode === 'create') createRoom(text)
            else if (mode === 'join') joinRoom(text)
          }
          return
        }
        if (key.name === 'backspace') {
          this.selectInput = this.selectInput.slice(0, -1)
          return
        }
        if (!key.ctrl && !key.meta && key.sequence && key.sequence >= ' ') {
          this.selectInput += key.sequence
          return
        }
        return
      }

      if (key.name === 'q') return { quit: true }
      if (key.sequence === 'c') { this.selectMode = 'create'; this.selectInput = ''; return }
      if (key.sequence === 'j') { this.selectMode = 'join'; this.selectInput = ''; return }
      if (key.sequence === 'g') { enterRegistry(); return }
      const num = parseInt(key.sequence)
      if (num >= 1 && num <= this.rooms.length) {
        selectRoom(this.rooms[num - 1])
      }
    },

    handleRegistryKey (key) {
      if (key.name === 'escape') { this.screen = 'room_select'; return }
      if (key.sequence === 'r') { loadRegistryListings(); return }
      if (key.sequence === 'a') { addAllRoomsToRegistry(); return }
      if (key.name === 'up') {
        if (this.registryListings.length) this.registryCursor = Math.max(0, this.registryCursor - 1)
        return
      }
      if (key.name === 'down') {
        if (this.registryListings.length) this.registryCursor = Math.min(this.registryListings.length - 1, this.registryCursor + 1)
        return
      }
      if (key.name === 'return') {
        const l = this.registryListings[this.registryCursor]
        if (l && l.inviteToken) joinFromRegistry(l)
        else if (l) this.srvInfo('listing has no inviteToken — skipping')
        return
      }
    },

    handleLiveKey (key) {
      // Enter: submit
      if (key.name === 'return') {
        const text = this.inputBuffer.trim()
        this.inputBuffer = ''
        this.cursorPos = 0
        if (text) {
          if (text.startsWith('/')) {
            handleCommand(text)
          } else if (this.activeBuffer > 0 && this.selectedRoom) {
            sendChat(text)
          } else {
            this.srvInfo('Switch to chat buffer (Alt+2) to chat, or use /commands')
          }
        }
        return
      }

      // Delegate to input-box handler
      const result = handleInputKey(this, key)
      if (result) {
        if (result.inputBuffer != null) this.inputBuffer = result.inputBuffer
        if (result.cursorPos != null) this.cursorPos = result.cursorPos
      }
    }
  }

  // ---- Daemon RPCs ----

  async function fetchRooms () {
    try { model.rooms = await client.rpc('rooms') || [] } catch { model.rooms = [] }
  }

  async function fetchIdentity () {
    try { model.identity = await client.rpc('identity') } catch { model.identity = null }
  }

  async function fetchMembers () {
    if (!model.selectedRoom) return
    try { model.members = await client.rpc('members', { roomId: model.selectedRoom.roomId }) || [] } catch { model.members = [] }
  }

  async function loadChatHistory () {
    if (!model.selectedRoom) return
    try {
      const msgs = await client.rpc('read', { roomId: model.selectedRoom.roomId, limit: 50 })
      if (!Array.isArray(msgs)) return
      const buf = model.findOrCreateRoomBuf(model.selectedRoom.roomId, model.selectedRoom.title)
      for (const m of msgs) {
        const msgId = m.seq != null ? String(m.seq) : (m.id || '')
        if (msgId && model._seenIds.has(msgId)) continue
        if (msgId) model._seenIds.add(msgId)
        const text = m.text || ''
        if (model.isMetadata(text)) continue
        const sender = m.name || (m.memberId ? m.memberId.slice(0, 8) : '?')
        if (m.system) {
          buf.lines.push({ time: '      ', text: s.dim('--- ' + text + ' ---') })
        } else if (text) {
          const ts = m.timestamp ? new Date(m.timestamp) : new Date()
          const time = String(ts.getHours()).padStart(2, '0') + ':' + String(ts.getMinutes()).padStart(2, '0') + ':' + String(ts.getSeconds()).padStart(2, '0')
          buf.lines.push({ time, text: s.cyan('<' + sender + '>') + ' ' + text })
        }
      }
    } catch {}
  }

  async function selectRoom (room) {
    model.selectedRoom = room
    model.members = []
    const buf = model.findOrCreateRoomBuf(room.roomId, room.title)
    model.srvInfo('room: ' + room.title)
    await fetchMembers()
    await loadChatHistory()
    // Subscribe for live chat
    try { await client.rpc('subscribe', { roomId: room.roomId }) } catch {}
    model.screen = 'live'
    const idx = model.buffers.indexOf(buf)
    model.activeBuffer = idx >= 0 ? idx : 0
    prog.render()
    // Phase 2 of hyper-fleeky-dev#32: auto-start the game on dedicated
    // peers where we're admin. Defer slightly so members/presence load
    // first, then make the decision.
    setTimeout(() => { maybeAutoStartGame().catch(() => {}) }, 1000)
  }

  // Auto-start logic (Phase 2 of hyper-fleeky-dev#32). Only fires in
  // --dedicated mode for now. Decision: am I admin AND is no live game
  // peer currently broadcasting? If yes, launch Godot. Manual /start
  // always works regardless.
  //
  // Retries up to 6× with 3s spacing if membership info isn't loaded
  // yet (race with keet-daemon's member fetch).
  async function maybeAutoStartGame (attempt = 0) {
    if (!dedicatedMode) return
    if (model.gameProcess) return
    if (model.config && model.config.auto_start === false) return
    if (!model.selectedRoom) return
    const MAX_ATTEMPTS = 6
    const RETRY_MS = 3000
    // Am I admin in this room? Try matching by memberId first, then by
    // displayName/username — different keet-daemon paths return
    // different shapes (per-room memberId vs global identity).
    const id = model.identity || {}
    const myMemberId = id.memberId || ''
    const myUsername = (id.username || '').toLowerCase()
    const myDisplay = (id.displayName || '').toLowerCase()
    let me = null
    if (myMemberId) me = model.members.find(m => m.memberId === myMemberId)
    if (!me && myUsername) me = model.members.find(m => (m.name || '').toLowerCase() === myUsername || (m.name || '').toLowerCase() === myUsername.replace(/\d+$/, ''))
    if (!me && myDisplay) me = model.members.find(m => (m.name || '').toLowerCase() === myDisplay)
    if (!me) {
      if (attempt < MAX_ATTEMPTS) {
        model.srvInfo('auto-start: my membership not found yet (attempt ' + (attempt + 1) + '/' + MAX_ATTEMPTS + '), retrying in ' + (RETRY_MS / 1000) + 's')
        setTimeout(() => {
          fetchMembers().then(() => maybeAutoStartGame(attempt + 1)).catch(() => {})
        }, RETRY_MS)
      } else {
        model.srvError('auto-start: gave up after ' + MAX_ATTEMPTS + ' attempts (identity=' + (id.username || id.displayName || '?') + '). Use /start to launch manually.')
      }
      return
    }
    // role can be either a string or a numeric code (1=admin per keet)
    const isAdmin = (me.role === 'admin') || (me.role === 1) || (typeof me.role === 'number' && me.role === 1)
    if (!isAdmin) {
      model.srvInfo('auto-start: not admin in this room (role=' + me.role + '), use /start to launch manually')
      return
    }
    // hyper-tps is P2P — every admin spawns their own Godot to join the
    // mesh. Just check presence as a sanity log; never skip the start.
    try {
      const meta = await client.rpc('meta.get', { roomId: model.selectedRoom.roomId })
      const now = Date.now() / 1000
      let liveCount = 0
      for (const k of Object.keys(meta || {})) {
        if (!k.startsWith('game:presence:')) continue
        let v = meta[k]
        if (typeof v === 'string') { try { v = JSON.parse(v) } catch {} }
        if (v && typeof v === 'object' && v.ts && now - v.ts < 60) liveCount++
      }
      const peerSummary = liveCount === 0 ? 'no live peers' : (liveCount + ' live peer(s) in mesh')
      model.srvEvent('auto-start: admin in room (' + peerSummary + ') — launching Godot')
    } catch {}
    startGame()
  }

  // ---- Global Registry ----

  // Pressed 'g' on room_select. If we haven't joined yet, join first. Then
  // load listings and switch to the registry screen.
  async function enterRegistry () {
    if (!model.registryJoined) {
      model.srvInfo('joining Hyper-Fleeky Global Registry…')
      try {
        await client.rpc('room.join', { invite: GLOBAL_REGISTRY_INVITE })
        model.registryJoined = true
        model.srvEvent('joined global registry')
        await fetchRooms()
      } catch (err) {
        model.srvError('global registry join failed: ' + (err && err.message))
        // Fall through to opening the screen anyway — it will show the error
        // and let the user retry with 'r' if the underlying issue is transient.
      }
    }
    model.screen = 'registry'
    await loadRegistryListings()
    prog.render()
  }

  async function loadRegistryListings () {
    model.registryLoading = true
    model.registryError = null
    prog.render()
    try {
      const result = await client.rpc('registry.list', { registryRoomId: GLOBAL_REGISTRY_ROOM_ID })
      model.registryListings = Array.isArray(result) ? result : (result && result.data) || []
      if (model.registryCursor >= model.registryListings.length) model.registryCursor = 0
    } catch (err) {
      model.registryError = err && err.message
      model.registryListings = []
    }
    model.registryLoading = false
    prog.render()
  }

  // Bulk-publish: mint a readOnly invite for every room the user is in
  // (skipping the registry itself) and post _hs:room-listing entries into
  // the global registry. Curator-only operation — non-writers' registry.add
  // will fail on the underlying autobase append; we report each per-room
  // result and continue.
  async function addAllRoomsToRegistry () {
    if (!model.rooms.length) { model.srvInfo('no rooms to list'); prog.render(); return }
    const candidates = model.rooms.filter(r => r.roomId !== GLOBAL_REGISTRY_ROOM_ID)
    if (!candidates.length) { model.srvInfo('only registry itself — nothing to list'); prog.render(); return }
    model.srvInfo('listing ' + candidates.length + ' room(s) in registry…')
    prog.render()
    let listed = 0, failed = 0
    for (const r of candidates) {
      try {
        // Use a regular (writable) invite — keet-core's _createInvitation
        // dispatches @keet/add-invitation once into the room's autobase, and
        // anyone holding the resulting token can join as a writer. NOT a
        // one-shot. readOnly is only correct for the registry room itself
        // (where readers should not write listings).
        const inv = await client.rpc('room.invite', { roomId: r.roomId, expiration: 0 })
        const inviteToken = (inv && (inv.token || inv)) || null
        if (!inviteToken) throw new Error('room.invite returned no token')
        await client.rpc('registry.add', {
          registryRoomId: GLOBAL_REGISTRY_ROOM_ID,
          roomId: r.roomId,
          title: r.title || '',
          inviteToken
        })
        listed++
        model.srvInfo('  + listed: ' + (r.title || r.roomId.slice(0, 8)))
        prog.render()
      } catch (err) {
        failed++
        model.srvError('  ✗ ' + (r.title || r.roomId.slice(0, 8)) + ': ' + (err && err.message))
        prog.render()
      }
    }
    model.srvEvent('registry: ' + listed + ' added, ' + failed + ' failed')
    await loadRegistryListings()
  }

  async function joinFromRegistry (listing) {
    model.srvInfo('joining ' + (listing.title || listing.roomId.slice(0, 8)) + '…')
    try {
      await client.rpc('room.join', { invite: listing.inviteToken })
      model.srvEvent('joined: ' + (listing.title || listing.roomId.slice(0, 8)))
      await fetchRooms()
      const r = model.rooms.find(x => x.roomId === listing.roomId)
      if (r) {
        await selectRoom(r)
        return
      }
      model.screen = 'room_select'
      prog.render()
    } catch (err) {
      model.srvError('join failed: ' + (err && err.message))
      prog.render()
    }
  }

  async function sendChat (text) {
    if (!model.selectedRoom) return
    try {
      model._selfSent.add(text)
      setTimeout(() => model._selfSent.delete(text), 5000)
      await client.rpc('send', { roomId: model.selectedRoom.roomId, text })
      const name = model.identity ? (model.identity.displayName || 'me') : 'me'
      model.chatMsg(model.selectedRoom.roomId, name, text)
      prog.render()
    } catch (err) {
      model._selfSent.delete(text)
      model.srvError('Chat error: ' + err.message)
      prog.render()
    }
  }

  async function handleCommand (line) {
    const parts = line.trim().split(/\s+/)
    const cmd = parts[0].toLowerCase()
    const arg1 = parts[1] || ''
    const arg2 = parts[2] || ''

    switch (cmd) {
      case '/start': startGame(); break
      case '/stop': stopGame(); break
      case '/quit': prog.quit(); break
      case '/invite':
        if (model.selectedRoom) {
          try {
            const r = await client.rpc('room.invite', { roomId: model.selectedRoom.roomId })
            const token = typeof r === 'string' ? r : (r && (r.token || r.invite)) || '(none)'
            model.srvMsg(s.green('Invite: ') + token)
          } catch (err) { model.srvError('Invite error: ' + err.message) }
        }
        break
      case '/home':
        if (model.selectedRoom) {
          model.config.home_room = model.selectedRoom.title
          model.config.home_room_id = model.selectedRoom.roomId
          model.config.auto_join = true
          saveConfig(model.config)
          model.srvEvent('Home room set: ' + model.selectedRoom.title)
        }
        break
      case '/rooms':
        await fetchRooms()
        model.screen = 'room_select'
        break
      case '/registry':
        await enterRegistry()
        break
      case '/diag':
        if (model.diagData) {
          const d = model.diagData
          if (d.swarm) model.srvInfo('swarm: ' + d.swarm.peers_connected + ' peers' + (d.swarm.flushed ? '' : ' (syncing)'))
          if (d.dht) model.srvInfo('dht: ' + (d.dht.bootstrapped ? 'bootstrapped' : 'connecting'))
          model.srvInfo('players: ' + (d.players_spawned || 0))
          if (d.connections) for (const c of d.connections) model.srvInfo('  #' + c.id + ' ' + c.state + (c.punched ? ' punched' : ''))
        } else { model.srvInfo('no diagnostics') }
        break
      case '/log':
        for (const l of model.gameLog.slice(-20)) model.srvInfo('[log] ' + l)
        break
      case '/clear':
        model.buf().lines = []
        break
      case '/kick':
        if (!arg1) { model.srvError('/kick <name>'); break }
        if (!model.selectedRoom) { model.srvError('No room selected'); break }
        try {
          const member = model.members.find(m => m.name.toLowerCase().includes(arg1.toLowerCase()))
          if (!member) { model.srvError('Member not found: ' + arg1); break }
          await client.rpc('kick', { roomId: model.selectedRoom.roomId, memberId: member.memberId })
          model.srvEvent('Kicked: ' + member.name)
        } catch (err) { model.srvError('Kick failed: ' + err.message) }
        break
      case '/promote':
        if (!arg1 || !arg2) { model.srvError('/promote <name> <role>'); break }
        if (!model.selectedRoom) { model.srvError('No room selected'); break }
        try {
          const member = model.members.find(m => m.name.toLowerCase().includes(arg1.toLowerCase()))
          if (!member) { model.srvError('Member not found: ' + arg1); break }
          const roleMap = { admin: 1, mod: 2, peer: 3, 'read-only': 4 }
          const roleInt = roleMap[arg2.toLowerCase()]
          if (!roleInt) { model.srvError('Invalid role: ' + arg2 + ' (admin/mod/peer/read-only)'); break }
          await client.rpc('members.role', { roomId: model.selectedRoom.roomId, memberId: member.memberId, role: roleInt })
          model.srvEvent('Role changed: ' + member.name + ' \u2192 ' + arg2)
        } catch (err) { model.srvError('Promote failed: ' + err.message) }
        break
      case '/demote':
        if (!arg1) { model.srvError('/demote <name>'); break }
        if (!model.selectedRoom) { model.srvError('No room selected'); break }
        try {
          const member = model.members.find(m => m.name.toLowerCase().includes(arg1.toLowerCase()))
          if (!member) { model.srvError('Member not found: ' + arg1); break }
          await client.rpc('members.role', { roomId: model.selectedRoom.roomId, memberId: member.memberId, role: 3 })
          model.srvEvent('Demoted to peer: ' + member.name)
        } catch (err) { model.srvError('Demote failed: ' + err.message) }
        break
      case '/map':
        if (!arg1) { model.srvError('/map <name>'); break }
        model.srvInfo('map: ' + arg1 + ' (not yet wired)')
        break
      case '/world': {
        const sub = (parts[1] || '').toLowerCase()
        if (!sub) { model.srvInfo('/world list [path] | get <path.prop> | set <path.prop> <value> | call <path.method> [args...]'); break }
        if (!model.selectedRoom) { model.srvError('/world: no room selected (admin commands go through room meta)'); break }
        try {
          let op, payload
          if (sub === 'list') {
            op = 'list'; payload = { path: parts[2] || '/' }
          } else if (sub === 'get') {
            if (!parts[2]) { model.srvError('/world get <path.prop>'); break }
            op = 'get'; payload = { path: parts[2] }
          } else if (sub === 'set') {
            if (parts.length < 4) { model.srvError('/world set <path.prop> <value>'); break }
            // Try to parse the value as JSON (numbers, bool, arrays); fall back to string.
            const raw = parts.slice(3).join(' ')
            let val
            try { val = JSON.parse(raw) } catch { val = raw }
            op = 'set'; payload = { path: parts[2], value: val }
          } else if (sub === 'call') {
            if (!parts[2]) { model.srvError('/world call <path.method> [json-args]'); break }
            const rawArgs = parts.slice(3).join(' ')
            let args = []
            if (rawArgs) {
              try { args = JSON.parse(rawArgs); if (!Array.isArray(args)) args = [args] } catch { args = [rawArgs] }
            }
            op = 'call'; payload = { path: parts[2], args }
          } else {
            model.srvError('/world: unknown subcommand: ' + sub); break
          }
          const seq = Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6)
          // AdminFacade subscribes to "admin:cmd:*" literally — no prefix.
          const cmdKey = 'admin:cmd:' + seq
          const resultKey = 'admin:result:' + seq
          const cmd = Object.assign({ op, requester_role: 'admin' }, payload)
          // state.set writes to the per-room StateBase Autobase, which
          // every keet-room peer's daemon replicates (see
          // keet-daemon/lib/state-base.js). Godot KeetGameState polls
          // state.list and emits state_updated → AdminFacade dispatches.
          await client.rpc('state.set', {
            roomId: model.selectedRoom.roomId,
            key: cmdKey,
            value: JSON.stringify(cmd)
          })
          // Poll state.list (returns { entries: { key: { value, ts, _by } } })
          // until our result key appears, up to 30s.
          const deadline = Date.now() + 30000
          let result = null
          let elapsed = 0
          model.srvInfo('/world ' + sub + ': waiting on authority…')
          while (Date.now() < deadline) {
            try {
              const resp = await client.rpc('state.get', {
                roomId: model.selectedRoom.roomId,
                key: resultKey
              })
              const raw = resp && resp.value
              if (raw !== undefined && raw !== null) {
                try { result = JSON.parse(raw) } catch { result = raw }
                break
              }
            } catch {}
            await new Promise(r => setTimeout(r, 500))
            elapsed += 500
            if (elapsed === 5000 || elapsed === 15000) {
              model.srvInfo('/world ' + sub + ': still waiting (' + (elapsed/1000) + 's)…')
            }
          }
          if (result === null) {
            model.srvError('/world ' + sub + ': no response within 5s (authority offline?)')
          } else if (result.ok === false) {
            model.srvError('/world ' + sub + ': ' + (result.error || 'failed'))
          } else {
            const pretty = typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result)
            for (const line of pretty.split('\n').slice(0, 30)) model.srvInfo(line)
          }
        } catch (err) { model.srvError('/world ' + sub + ' failed: ' + err.message) }
        break
      }
      case '/who': {
        // Live presence — read presence:* entries from room meta.
        // Filter to entries fresher than PRESENCE_FRESH_S so stale
        // entries from prior crashed/ungracefully-closed sessions don't
        // pollute the live list. Per-session DHT keypairs mean each
        // game launch creates a new pk; without heartbeating, stale pks
        // persist in autobase indefinitely. The real Phase 2.5 fix is
        // either heartbeating or keying on stable Keet memberId — until
        // then, freshness filtering is the practical surface.
        const PRESENCE_FRESH_S = 60
        if (!model.selectedRoom) { model.srvError('/who: no room selected'); break }
        try {
          const meta = await client.rpc('meta.get', { roomId: model.selectedRoom.roomId })
          const live = []
          const stale = []
          const now = Date.now() / 1000
          for (const k of Object.keys(meta || {})) {
            if (!k.startsWith('game:presence:')) continue
            let v = meta[k]
            if (typeof v === 'string') {
              try { v = JSON.parse(v) } catch {}
            }
            if (!v || typeof v !== 'object') continue
            const entry = { pk: v.pk || k.slice('game:presence:'.length), ts: v.ts || 0, role: v.role || 'peer', name: v.name || '' }
            if ((now - entry.ts) <= PRESENCE_FRESH_S) live.push(entry)
            else stale.push(entry)
          }
          if (live.length === 0) {
            model.srvInfo('/who: no live presence (no game peer is broadcasting yet — /start one)')
          } else {
            live.sort((a, b) => b.ts - a.ts)
            model.srvInfo('/who: ' + live.length + ' live in-game peer(s):')
            for (const e of live) {
              const age = Math.max(0, Math.floor(now - e.ts))
              const label = e.name ? (e.name + ' (' + e.pk.slice(0, 12) + ')') : e.pk.slice(0, 16)
              model.srvInfo('  ' + label + '   role=' + e.role + '   ' + age + 's ago')
            }
          }
          if (stale.length > 0) {
            model.srvInfo('  (' + stale.length + ' stale entries older than ' + PRESENCE_FRESH_S + 's hidden)')
          }
        } catch (err) { model.srvError('/who failed: ' + err.message) }
        break
      }
      case '/help':
        model.srvInfo('Commands: /start /stop /kick /promote /demote /invite /map /home /diag /log /rooms /clear /quit /who')
        model.srvInfo('Admin: /world list [path] | get <path.prop> | set <path.prop> <value> | call <path.method> [args]')
        model.srvInfo('Keys: Alt+1..9 buffer, Tab cycle, PgUp/PgDn scroll, Ctrl+C quit')
        break
      default:
        model.srvError('Unknown: ' + cmd + ' (try /help)')
    }
    prog.render()
  }

  async function createRoom (title) {
    try {
      const result = await client.rpc('room.create', { title })
      const roomId = typeof result === 'string' ? result : (result && result.roomId) || ''
      if (roomId) {
        model.srvEvent('Room created: ' + title)
        await fetchRooms()
        selectRoom({ roomId, title })
      }
    } catch (err) { model.srvError('Create failed: ' + err.message); prog.render() }
  }

  async function joinRoom (invite) {
    try {
      await client.rpc('room.join', { invite })
      model.srvEvent('Joined via invite')
      await fetchRooms()
      model.screen = 'room_select'
      prog.render()
    } catch (err) { model.srvError('Join failed: ' + err.message); prog.render() }
  }

  // ---- Game lifecycle ----

  function startGame () {
    if (!model.selectedRoom || model.gameProcess) {
      if (model.gameProcess) model.srvInfo('game running (pid ' + model.gameProcess.pid + ')')
      return
    }
    const args = ['--verbose', '--headless', '--',
      '--version=' + model.appVersion,
      '--room-id', model.selectedRoom.roomId,
      '--role', model.config.role || 'admin',
      '--diag-path', model.diagPath
    ]
    // Stable cwd: the install tree (buildDir) is swapped atomically by the
    // background `pgh app update`, which would unlink the cwd inode mid-run
    // → getcwd() null → DirAccessUnix/user:// failures. See index.js spawn.
    model.gameProcess = subprocess.spawn(gamePath, args, {
      stdio: ['ignore', 'pipe', 'pipe'], cwd: os.homedir()
    })
    model.gameLog = []
    model.diagData = null
    model.srvEvent('game started (pid ' + model.gameProcess.pid + ')')

    model.gameProcess.stdout.on('data', (chunk) => {
      for (const l of chunk.toString().split('\n').filter(l => l.trim())) {
        model.gameLog.push(l)
        if (model.gameLog.length > 500) model.gameLog.shift()
        if (/peer_connected|peer_disconnected|spawned|Terrain ready|authority elected|mesh active/.test(l)) {
          model.srvEvent(l.replace(/^\[.*?\]\s*/, ''))
          prog.render()
        }
      }
    })
    model.gameProcess.stderr.on('data', (chunk) => {
      for (const l of chunk.toString().split('\n').filter(l => l.trim())) {
        model.gameLog.push('[ERR] ' + l)
        if (model.gameLog.length > 500) model.gameLog.shift()
      }
    })
    model.gameProcess.on('exit', (code) => {
      model.srvEvent('game exited (code ' + code + ')')
      model.gameProcess = null
      prog.render()
    })
  }

  function stopGame () {
    if (!model.gameProcess) { model.srvInfo('no game running'); return }
    model.srvInfo('stopping game...')
    try { model.gameProcess.kill() } catch {}
    model.gameProcess = null
    model.diagData = null
  }

  // ---- Daemon events ----

  let globalRegistryAttempted = false
  async function maybeJoinGlobalRegistry () {
    // TEMPORARY DISABLE (regression bisect — hyper-fleeky-dev autobase-closing chat bug).
    // The room.join / registry.rooms RPCs are firing during keet-core's still-pending
    // _runBootOnline, racing the boot path and leaving rooms in a "broken" state for
    // the lifetime of the daemon. Re-enable once the boot-order race is fixed in
    // keet-daemon (don't allow room ops until onlineBoot.ready()).
    return
    // eslint-disable-next-line no-unreachable
    if (globalRegistryAttempted) return
    if (!GLOBAL_REGISTRY_ROOM_ID || !GLOBAL_REGISTRY_INVITE) return // pre-bootstrap
    globalRegistryAttempted = true
    try {
      const regs = await client.rpc('registry.rooms', {})
      const list = Array.isArray(regs) ? regs : []
      if (list.some(r => r && r.roomId === GLOBAL_REGISTRY_ROOM_ID)) {
        model.registryJoined = true
        return
      }
      await client.rpc('room.join', { invite: GLOBAL_REGISTRY_INVITE })
      model.registryJoined = true
      model.srvEvent('joined global registry ' + GLOBAL_REGISTRY_ROOM_ID.slice(0, 8))
      await fetchRooms()
    } catch (err) {
      model.srvInfo('global-registry join skipped: ' + (err && err.message))
    }
  }

  client.on('connected', async () => {
    model.connected = true
    model.srvEvent('daemon connected')
    await fetchRooms()
    await fetchIdentity()
    if (model.identity) model.srvInfo('identity: ' + (model.identity.displayName || model.identity.username || '?'))
    if (model.identity) await maybeJoinGlobalRegistry() // needs identity loaded

    if (model.config.auto_join && model.config.home_room_id) {
      const home = model.rooms.find(r => r.roomId === model.config.home_room_id)
      if (home) { selectRoom(home); return }
    }
    model.screen = 'room_select'
    prog.render()
  })

  client.on('disconnected', () => {
    model.connected = false
    model.srvError('daemon disconnected \u2014 reconnecting...')
    if (model.screen !== 'live') model.screen = 'connecting'
    prog.render()
  })

  client.on('event', (msg) => {
    if (msg.event === 'message' && msg.data && model.selectedRoom) {
      const msgId = msg.data.seq != null ? String(msg.data.seq) : (msg.data.id || '')
      if (msgId && model._seenIds.has(msgId)) return
      if (msgId) model._seenIds.add(msgId)
      const text = msg.data.text || msg.data.message || ''
      if (model.isMetadata(text)) return
      if (text && model._selfSent.has(text)) { model._selfSent.delete(text); return }
      const sender = msg.data.name || msg.data.sender || '?'
      if (text) {
        model.chatMsg(model.selectedRoom.roomId, sender, text)
        prog.render()
      }
    }
  })

  // ---- Polling ----

  async function onPoll (prog) {
    if (model.gameProcess) {
      try { model.diagData = JSON.parse(fs.readFileSync(model.diagPath, 'utf-8')) } catch { model.diagData = null }
    }
    if (model.selectedRoom) {
      const prevCount = model.members.length
      await fetchMembers()
      // Only render if member list changed (Program renders after onPoll anyway)
    }
  }

  // ---- Start ----

  const prog = new Program(model, { pollInterval: 5000, onPoll })
  prog.run()

  // Redirect daemon console output to server buffer (instead of clobbering TUI)
  const _origLog = console.log
  const _origErr = console.error
  const _origWarn = console.warn
  console.log = (...args) => {
    const line = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')
    model.srvInfo(line)
    prog.render()
  }
  console.error = (...args) => {
    const line = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')
    model.srvError(line)
    prog.render()
  }
  console.warn = console.error

  client.connect()

  return new Promise(() => {})
}