OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scripts / gamestate-uds-test.cjs

138 lines · javascript ref:
#!/usr/bin/env node
// gamestate-uds-test.cjs — e2e GAMESTATE audit driven entirely through the
// keet-daemon UDS (the --daemon-only agent interface), against a FRESHLY-CREATED
// real room. Covers every world-state family the game actually writes (derived
// from probing real rooms): settings, players, creatures, terrain edits, objects,
// presence, members, chat — plus the world-init-seed idempotency semantics.
//
// Run via gamestate-uds-test.sh (boots a daemon on isolated storage). Or set
// KEET_SOCK to a running daemon.
//
// Wire contract (verified vs game_state.gd / keet_game_state.gd / router.js):
//   • state.set {roomId,key,value}; value is a GDScript var_to_str LITERAL string.
//   • world:<family> keys ending in MAP_SCOPED_PREFIXES → map:<mapId>:world:<...>.
//   • Response shape is inconsistent: some {ok,data:<result>}, some spread.
'use strict'
const net = require('net')
const SOCK = process.env.KEET_SOCK || '/tmp/keet-gstest.sock'

let _sock, _buf = Buffer.alloc(0), _id = 0, _waiters = new Map()
function connect () {
  return new Promise((res, rej) => {
    _sock = net.connect(SOCK)
    _sock.on('data', c => { _buf = Buffer.concat([_buf, c]); while (_buf.length >= 4) { const n = _buf.readUInt32LE(0); if (_buf.length < 4 + n) break; const m = JSON.parse(_buf.slice(4, 4 + n).toString()); _buf = _buf.slice(4 + n); const w = _waiters.get(m.id); if (w) { _waiters.delete(m.id); w(m) } } })
    _sock.once('connect', res); _sock.once('error', rej)
  })
}
function rpc (method, params, timeoutMs = 15000) {
  return new Promise((resolve, reject) => {
    const id = ++_id
    const t = setTimeout(() => { _waiters.delete(id); reject(new Error('timeout ' + method)) }, timeoutMs)
    _waiters.set(id, m => { clearTimeout(t); if (m.ok === false || m.error) reject(new Error(m.error || 'rpc failed')); else resolve(m.data !== undefined ? m.data : m) })
    const b = Buffer.from(JSON.stringify({ id, method, params })); const l = Buffer.alloc(4); l.writeUInt32LE(b.length, 0); _sock.write(Buffer.concat([l, b]))
  })
}
const uw = v => (v && typeof v === 'object' && 'value' in v) ? v.value : v
const sleep = ms => new Promise(r => setTimeout(r, ms))

let pass = 0, fail = 0; const fails = []
async function test (name, fn) {
  try { await fn(); pass++; console.log('  ✓', name) }
  catch (e) { fail++; fails.push([name, e.message]); console.log('  ✗', name, '—', e.message) }
}
function assert (c, m) { if (!c) throw new Error(m || 'assert failed') }

// ─── a durable set→get round-trip for a given key family ───
async function roundtrip (roomId, key, value, expectIncludes) {
  await rpc('state.set', { roomId, key, value })
  const got = uw((await rpc('state.get', { roomId, key })).value)
  assert(typeof got === 'string' && expectIncludes.every(s => got.includes(s)), `key ${key}: stored ${JSON.stringify(got)}`)
}

async function main () {
  await connect(); console.log('connected', SOCK)
  const MAP = 'map_dc_terrain', MEM = 'testmember0001'
  let roomId

  // ── foundation ──
  await test('room.create → roomId (no Autobase-is-closing)', async () => {
    const r = await rpc('room.create', { title: 'gstest-' + Date.now() }, 30000)
    roomId = (typeof r === 'string') ? r : (r.roomId || r.data?.roomId)
    assert(roomId && typeof roomId === 'string', 'no roomId: ' + JSON.stringify(r).slice(0, 100))
  })
  if (!roomId) { report(); process.exit(1) }
  console.log('  room:', roomId.slice(0, 16))
  await sleep(1500)

  await test('state.info — StateBase writable', async () => {
    const r = await rpc('state.info', { roomId }); assert(r.writable === true, 'writable=' + r.writable); assert(r.stateBaseKey, 'no key')
  })

  // ── world-state families (each a real key family seen in live rooms) ──
  await test('settings: world:settings:current_map round-trip', () =>
    roundtrip(roomId, 'world:settings:current_map', '{ "path": "res://scenes/maps/map_dc_terrain.tscn", "ts": 1 }', ['path', 'map_dc_terrain']))

  await test('player: map:<m>:world:players:<id> position (move-character)', () =>
    roundtrip(roomId, `map:${MAP}:world:players:${MEM}`, `{ "memberId": "${MEM}", "pos": [12.0, 5.0, -7.0], "ts": 1 }`, ['"pos"', '12', '-7']))

  await test('creature: map:<m>:world:creatures:registry:<id>', () =>
    roundtrip(roomId, `map:${MAP}:world:creatures:registry:crawler_t`, '{ "pos": [1.0, 2.0, 3.0], "seed": 42, "type": "crawler" }', ['crawler', '"seed"']))

  await test('terrain: map:<m>:world:terrain:edit:<id>', () =>
    roundtrip(roomId, `map:${MAP}:world:terrain:edit:e1`, '{ "ext": [3.0, 3.0, 3.0], "pos": [1.0, 0.0, 2.0], "str": -3.0 }', ['"ext"', '"str"']))

  await test('object: map:<m>:world:objects:<id>', () =>
    roundtrip(roomId, `map:${MAP}:world:objects:o1`, '{ "pos": [0.0, 1.0, 0.0], "kind": "crate" }', ['crate']))

  await test('presence: presence:<memberId> (room-global)', () =>
    roundtrip(roomId, `presence:${MEM}`, `{ "memberId": "${MEM}", "name": "tester", "ready": true }`, ['tester']))

  // ── list / delete / membership ──
  await test('state.list by prefix returns the map-scoped families', async () => {
    const r = await rpc('state.list', { roomId, prefix: `map:${MAP}:world:` })
    const keys = Object.keys(r.entries || {})
    for (const need of ['players:' + MEM, 'creatures:registry:crawler_t', 'terrain:edit:e1', 'objects:o1'])
      assert(keys.some(k => k.includes(need)), `missing ${need} (${keys.length} keys)`)
  })

  await test('state.del removes a key', async () => {
    const k = `map:${MAP}:world:objects:o1`
    await rpc('state.del', { roomId, key: k })
    assert(uw((await rpc('state.get', { roomId, key: k })).value) == null, 'not deleted')
  })

  await test('members.count / active / mods reachable', async () => {
    for (const m of ['members.count', 'members.active', 'members.mods']) await rpc(m, { roomId })
  })

  // ── world-init seed semantics (what game.gd:_restore_saved_map does) ──
  await test('world-init seed is idempotent (set-only-if-absent, no clobber)', async () => {
    const k = 'world:settings:current_map'
    const before = uw((await rpc('state.get', { roomId, key: k })).value)  // already set by an earlier test
    assert(before != null, 'precondition: settings present')
    // guard: only seed when ABSENT → present → SKIP (no overwrite)
    const cur = uw((await rpc('state.get', { roomId, key: k })).value)
    if (cur == null) await rpc('state.set', { roomId, key: k, value: '{"path":"DEFAULT"}' })
    const after = uw((await rpc('state.get', { roomId, key: k })).value)
    assert(after === before, 'idempotent seed clobbered existing value')
  })

  // ── health + chat (the originally-broken symptom) ──
  await test('state.diag — room not broken, writable', async () => {
    const r = await rpc('state.diag', { roomId }); assert(r.broken == null, 'broken'); assert(r.writable === true, 'not writable')
  })

  await test('chat send + read-back (new messages flow)', async () => {
    const stamp = 'gstest-msg-' + Date.now(); await rpc('send', { roomId, text: stamp }); await sleep(900)
    const r = await rpc('read', { roomId }); assert(JSON.stringify(r).includes(stamp), 'sent msg not read back')
  })

  report(); try { _sock.destroy() } catch {}; process.exit(fail === 0 ? 0 : 1)
}
function report () {
  console.log('\n=== GAMESTATE UDS e2e (#5 audit) ===')
  console.log(`  ${pass} passed, ${fail} failed`)
  for (const [n, e] of fails) console.log('  FAIL:', n, '—', e)
}
main().catch(e => { console.error('SUITE ERROR:', e.message); process.exit(2) })