#!/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) })