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
}
// AUTHOR-SCOPED: admin:cmd:<memberId>:<seq>. AdminFacade takes the role from
// this author's ROOM MEMBERSHIP; a role written into the command is ignored
// (it used to be hard-coded 'admin' here, which any member could copy).
// memberId is the same identity RPC the game uses for its own (KeetService).
if (!model.identity || !model.identity.memberId) {
try { model.identity = await client.rpc('identity') } catch {}
}
const me = (model.identity && model.identity.memberId) || ''
if (!me) { model.srvError('/world: no identity yet — cannot sign a command'); break }
const seq = me + ':' + 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 }, 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(() => {})
}