OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / distribute / hypertps-win64 / scripts / admin-cli.cjs

210 lines · javascript ref:
#!/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()