#!/usr/bin/env node
// admin-cli.cjs — talk to a running hyper-tps's keet-daemon directly over
// UDS, without launching the TUI. Drives the per-room StateBase autobase
// (state.* RPCs) that backs hyper-tps game state since 0.512.
//
// IMPORTANT wire contract (verified against keet_game_state.gd +
// game_state.gd + admin_facade.gd):
// • StateBase keys are stored VERBATIM. World state that AdminFacade
// reflects onto Godot nodes uses the prefix `state:` followed by the
// admin path, e.g. state:/world/Environment/sky.current_time
// • Values are GDScript `var_to_str` literals, NOT JSON. The Godot side
// does str_to_var() on read. So a float is `12.0`, a string is
// `"hello"` (with quotes), a vector is `Vector3(1, 2, 3)`, bool `true`.
// The value you pass here is sent through UNCHANGED as that literal.
//
// Usage:
// node scripts/admin-cli.cjs rooms
// node scripts/admin-cli.cjs identity
// node scripts/admin-cli.cjs state-list <roomId> [keyPrefix]
// node scripts/admin-cli.cjs state-get <roomId> <key>
// node scripts/admin-cli.cjs state-set <roomId> <key> <gdscriptValue>
// node scripts/admin-cli.cjs state-del <roomId> <key>
// node scripts/admin-cli.cjs world-set <roomId> <path.prop> <gdscriptValue>
// node scripts/admin-cli.cjs raw <method> <jsonParams>
//
// world-set is the ergonomic path for the e2e: it prepends the `state:`
// prefix so AdminFacade applies the value to /world/<path>.<prop>.
// world-set <room> /world/Environment/sky.current_time 12.0
// → state.set key=state:/world/Environment/sky.current_time value=12.0
//
// Defaults to UDS /tmp/keet-daemon.sock. Override with KEET_SOCK=...
const net = require('net')
const SOCK = process.env.KEET_SOCK || '/tmp/keet-daemon.sock'
function framed (sock) {
let buf = Buffer.alloc(0)
return {
write (obj) {
const body = Buffer.from(JSON.stringify(obj), 'utf-8')
const len = Buffer.alloc(4)
len.writeUInt32LE(body.length, 0)
sock.write(Buffer.concat([len, body]))
},
onMessage (cb) {
sock.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk])
while (buf.length >= 4) {
const len = buf.readUInt32LE(0)
if (buf.length < 4 + len) break
const body = buf.slice(4, 4 + len)
buf = buf.slice(4 + len)
try { cb(JSON.parse(body.toString('utf-8'))) }
catch (e) { cb({ id: 0, ok: false, error: 'parse: ' + e.message }) }
}
})
}
}
}
function rpc (method, params, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
const sock = net.connect(SOCK)
const f = framed(sock)
const id = Math.floor(Math.random() * 1e9)
const timer = setTimeout(() => {
sock.destroy()
reject(new Error('timeout waiting for ' + method))
}, timeoutMs)
f.onMessage((msg) => {
if (msg.id !== id) return
clearTimeout(timer)
sock.destroy()
if (msg.ok === false || msg.error) reject(new Error(msg.error || 'rpc failed'))
else resolve(msg.data !== undefined ? msg.data : msg)
})
sock.once('connect', () => f.write({ id, method, params }))
sock.once('error', (err) => { clearTimeout(timer); reject(err) })
})
}
// state.list / state.get return entries shaped { value, ts, _by } where
// `value` is the var_to_str literal the Godot side stored. Pull the inner
// literal out for display; RAW_ENTRIES=1 keeps the full envelope.
const RAW_ENTRIES = process.env.RAW_ENTRIES === '1'
function unwrap (entry) {
if (RAW_ENTRIES) return entry
if (entry && typeof entry === 'object' && 'value' in entry) return entry.value
return entry
}
async function main () {
const [cmd, ...args] = process.argv.slice(2)
try {
if (cmd === 'rooms') {
console.log(JSON.stringify(await rpc('rooms'), null, 2))
} else if (cmd === 'identity') {
console.log(JSON.stringify(await rpc('identity'), null, 2))
} else if (cmd === 'state-list') {
const [roomId, prefix] = args
if (!roomId) throw new Error('state-list <roomId> [keyPrefix]')
const res = await rpc('state.list', { roomId, prefix: prefix || '' })
const entries = (res && res.entries) || {}
const out = {}
for (const k of Object.keys(entries)) out[k] = unwrap(entries[k])
console.log(JSON.stringify(out, null, 2))
} else if (cmd === 'state-get') {
const [roomId, key] = args
if (!roomId || !key) throw new Error('state-get <roomId> <key>')
const res = await rpc('state.get', { roomId, key })
// _stateGet returns the stored entry (or null). Unwrap to the literal.
const v = unwrap(res && res.value !== undefined ? res.value : res)
console.log(v === undefined || v === null ? 'NULL' : (typeof v === 'string' ? v : JSON.stringify(v)))
} else if (cmd === 'state-set') {
const [roomId, key, gdValue] = args
if (!roomId || !key || gdValue === undefined) {
throw new Error('state-set <roomId> <key> <gdscriptValue> (value is a var_to_str literal, e.g. 12.0 or "hi")')
}
const res = await rpc('state.set', { roomId, key, value: gdValue })
console.log(JSON.stringify(res, null, 2))
} else if (cmd === 'state-del') {
const [roomId, key] = args
if (!roomId || !key) throw new Error('state-del <roomId> <key>')
console.log(JSON.stringify(await rpc('state.del', { roomId, key }), null, 2))
} else if (cmd === 'world-set') {
const [roomId, pathProp, gdValue] = args
if (!roomId || !pathProp || gdValue === undefined) {
throw new Error('world-set <roomId> <path.prop> <gdscriptValue> e.g. world-set R /world/Environment/sky.current_time 12.0')
}
// AdminFacade reflects state:<path>.<prop> onto Godot nodes.
const key = 'state:' + pathProp
const res = await rpc('state.set', { roomId, key, value: gdValue })
console.log('set ' + key + ' = ' + gdValue)
console.log(JSON.stringify(res, null, 2))
} else if (cmd === 'diag') {
// Live read-only StateBase health on the RUNNING daemon (the same one the
// game uses). No roomId → every open room. Use to characterize the
// "writer not set" issue: writable / writers / indexers / swarmPeers /
// __members__ / selfInMembers / broken — exactly what the daemon sees.
const [roomId] = args
console.log(JSON.stringify(await rpc('state.diag', roomId ? { roomId } : {}), null, 2))
} else if (cmd === 'positions') {
// Durable player/object positions for a room (world:* keys).
const [roomId, prefix] = args
if (!roomId) throw new Error('positions <roomId> [prefix=world:]')
const res = await rpc('state.list', { roomId, prefix: prefix || 'world:' })
const entries = (res && res.entries) || {}
const out = {}
for (const k of Object.keys(entries)) out[k] = unwrap(entries[k])
console.log(JSON.stringify(out, null, 2))
} else if (cmd === 'move-character') {
// Durable character move over UDS: write a peer's last-known position so
// they (re)spawn there on next join. Mirrors player_position.gd EXACTLY:
// key = world:players:<memberId>, MAP_SCOPED → map:<mapId>:world:players:<memberId>
// when a mapId is given (game_state.gd:_scoped / MAP_SCOPED_PREFIXES).
// value= var_to_str({ memberId, pos:[x,y,z], ts }) — GDScript literal, NOT JSON
// (keet_game_state.gd:147 set_value → str_to_var on read).
// This is the DURABLE plane (per #97). It does NOT live-move an active remote
// character — that's the realtime player:pos:* mesh (separate track).
const [roomId, memberId, xs, ys, zs, mapId] = args
if (!roomId || !memberId || xs === undefined || ys === undefined || zs === undefined) {
throw new Error('move-character <roomId> <memberId> <x> <y> <z> [mapId]')
}
const x = Number(xs), y = Number(ys), z = Number(zs)
if (![x, y, z].every(Number.isFinite)) throw new Error('x/y/z must be finite numbers')
const baseKey = 'world:players:' + memberId
const key = mapId ? ('map:' + mapId + ':' + baseKey) : baseKey
const ts = Math.floor(Date.now() / 1000)
// var_to_str numeric literals: emit floats so Godot reads Vector3 components
// as floats (1 → "1.0"). Dict keys are quoted; str_to_var tolerates whitespace.
const f = (n) => (Number.isInteger(n) ? n.toFixed(1) : String(n))
const value = '{ "memberId": "' + memberId + '", "pos": [' +
f(x) + ', ' + f(y) + ', ' + f(z) + '], "ts": ' + ts + ' }'
const res = await rpc('state.set', { roomId, key, value })
console.log('move-character: set ' + key + ' = ' + value)
console.log(JSON.stringify(res, null, 2))
// Read back to confirm the durable write round-trips through the StateBase.
const back = await rpc('state.get', { roomId, key })
console.log('readback: ' + JSON.stringify(unwrap(back && back.value !== undefined ? back.value : back)))
} else if (cmd === 'raw') {
const [method, jsonParams] = args
const params = jsonParams ? JSON.parse(jsonParams) : {}
console.log(JSON.stringify(await rpc(method, params), null, 2))
} else {
console.error('Usage:')
console.error(' admin-cli.cjs rooms')
console.error(' admin-cli.cjs identity')
console.error(' admin-cli.cjs state-list <roomId> [keyPrefix]')
console.error(' admin-cli.cjs state-get <roomId> <key>')
console.error(' admin-cli.cjs state-set <roomId> <key> <gdscriptValue>')
console.error(' admin-cli.cjs state-del <roomId> <key>')
console.error(' admin-cli.cjs world-set <roomId> <path.prop> <gdscriptValue>')
console.error(' admin-cli.cjs diag [roomId] (live StateBase health: writable/writers/members)')
console.error(' admin-cli.cjs positions <roomId> [prefix] (durable world:* positions/items)')
console.error(' admin-cli.cjs move-character <roomId> <memberId> <x> <y> <z> [mapId] (durable position; respawns peer there)')
console.error(' admin-cli.cjs raw <method> <jsonParams>')
console.error('')
console.error('Values are GDScript var_to_str literals (12.0, "hi", true, Vector3(1, 2, 3)).')
console.error('KEET_SOCK overrides the UDS path (default /tmp/keet-daemon.sock).')
process.exit(2)
}
} catch (err) {
console.error('ERR ' + err.message)
process.exit(1)
}
}
main()