// Headless smoke test: spawns its own server instance, connects two WS players, // verifies join/sync/movement/critter-hunt/PvP-kill/chat. Exits 0 on success. 'use strict'; import { spawn } from 'node:child_process'; const PORT = 8123 + Math.floor(Math.random() * 400); const WS_URL = `ws://127.0.0.1:${PORT}/ws`; let failures = 0; function ok(cond, label) { console.log(`${cond ? 'PASS' : 'FAIL'} ${label}`); if (!cond) failures++; } async function waitReady(port, tries = 40) { for (let i = 0; i < tries; i++) { try { const r = await fetch(`http://127.0.0.1:${port}/`); if (r.ok) return true; } catch {} await sleep(150); } return false; } class Client { constructor(name, sp) { this.name = name; this.sp = sp; this.handlers = []; this.snapshots = []; this.ws = new WebSocket(WS_URL); this.ws.addEventListener('message', (ev) => { let m; try { m = JSON.parse(ev.data); } catch { return; } if (m.t === 's') this.snapshots.push(m); this.handlers.forEach(h => h(m)); }); this.opened = new Promise((res, rej) => { this.ws.addEventListener('open', res); this.ws.addEventListener('error', rej); }); } send(o) { this.ws.send(JSON.stringify(o)); } async join() { await this.opened; const p = this.waitFor(m => m.t === 'welcome'); this.send({ t: 'join', name: this.name, sp: this.sp }); return p; } waitFor(pred, timeout = 8000) { return new Promise((res, rej) => { const h2 = (m) => { if (pred(m)) { this.handlers = this.handlers.filter(x => x !== h2); res(m); } }; this.handlers.push(h2); setTimeout(() => rej(new Error('timeout waiting: ' + (pred.toString().slice(0, 60)))), timeout); }); } latest() { return this.snapshots[this.snapshots.length - 1]; } close() { try { this.ws.close(); } catch {} } } const sleep = (ms) => new Promise(r => setTimeout(r, ms)); // ---- start isolated server ---- const srv = spawn('node', ['server.js'], { cwd: new URL('..', import.meta.url).pathname, env: { ...process.env, PORT: String(PORT), DEBUG_CHEATS: '1' }, }); srv.on('error', (e) => console.error('server spawn error', e)); srv.stderr.on('data', (d) => process.stderr.write('[srv] ' + d)); if (!(await waitReady(PORT))) { console.log('FAIL server did not start'); process.exit(1); } try { const A = new Client('AlphaRex', 'rex'); const B = new Client('BetaRaptor', 'raptor'); const wA = await A.join(); await B.join(); ok(wA.id > 0 && wA.map && wA.map.data.length > 1000, 'welcome contains map data'); ok(wA.species && wA.species.rex, 'welcome contains species table'); await sleep(800); // 1) teleport A next to B (facing right toward B) -> both should see each other const youB = B.latest().you; A.send({ t: 'tp', x: youB.x - 60, y: youB.y, a: 0 }); await sleep(700); ok(A.latest().ents.some(e => e.k === 'p'), 'player A sees player B after approaching'); ok(B.latest().ents.some(e => e.k === 'p' && e.n === 'AlphaRex'), 'player B sees AlphaRex'); // 2) movement changes position const before = A.latest().you; A.send({ t: 'in', u: 1 }); await sleep(1000); A.send({ t: 'in', u: 0 }); const after = A.latest().you; ok(Math.abs(after.y - before.y) > 30, `movement moves dino (${before.y} -> ${after.y})`); // 3) hunt a critter: teleport near one facing it, bite until it dies & feeds us let hunted = false; outer: for (let attempt = 0; attempt < 25 && !hunted; attempt++) { let snap = A.latest(); let critter = snap && snap.ents.find(e => e.k === 'c'); if (!critter) { // hop somewhere random on land to find fauna for (let i = 0; i < 40; i++) { const x = 400 + Math.random() * 6800, y = 400 + Math.random() * 6800; A.send({ t: 'tp', x, y }); await sleep(160); snap = A.latest(); critter = snap && snap.ents.find(e => e.k === 'c'); if (critter) break; } if (!critter) continue; } const id = critter.i; A.send({ t: 'tp', x: critter.x - 34, y: critter.y, a: 0 }); await sleep(120); const fdBefore = A.latest().you.fd; for (let i = 0; i < 7; i++) { A.send({ t: 'in', bt: 1, r: 0.001 ? 0 : undefined }); // bite, no move keys A.send({ t: 'in', bt: 0 }); await sleep(280); const s2 = A.latest(); if (!s2.ents.some(e => e.k === 'c' && e.i === id)) { // it died hunted = true; break; } } const fdAfter = A.latest().you.fd; if (hunted && fdAfter > fdBefore) break; if (!hunted) { /* critter escaped; try another */ } } ok(hunted, 'biting kills a critter'); // 3b) hunt an AI herd dino -> it drops a carcass let aiKilled = false; for (let attempt = 0; attempt < 20 && !aiKilled; attempt++) { let snap = A.latest(); let ai = snap && snap.ents.find(e => e.k === 'd'); if (!ai) { for (let i = 0; i < 40 && !ai; i++) { const x = 400 + Math.random() * 6800, y = 400 + Math.random() * 6800; A.send({ t: 'tp', x, y }); await sleep(170); snap = A.latest(); ai = snap && snap.ents.find(e => e.k === 'd'); } if (!ai) continue; } const id = ai.i, lx = ai.x, ly = ai.y; A.send({ t: 'tp', x: ai.x - 30, y: ai.y, a: 0 }); await sleep(100); for (let i = 0; i < 10; i++) { A.send({ t: 'in', bt: 1 }); await sleep(300); A.send({ t: 'in', bt: 0 }); const s2 = A.latest(); if (!s2.ents.some(e => e.k === 'd' && e.i === id)) { aiKilled = true; break; } // chase: re-tp onto its last seen spot if it fled const cur = s2.ents.find(e => e.k === 'd' && e.i === id); if (cur && Math.hypot(cur.x - A.latest().you.x, cur.y - A.latest().you.y) > 70) A.send({ t: 'tp', x: cur.x - 34, y: cur.y, a: 0 }); } if (!aiKilled) continue; await sleep(300); aiKilled = A.latest().ents.some(e => e.k === 'k' && Math.hypot(e.x - lx, e.y - ly) < 400); } ok(aiKilled, 'hunting AI dino drops carcass'); // 4) PvP: rex devours raptor -> death screen on victim, carcass drops const deadP = B.waitFor(m => m.t === 'dead', 25000); for (let i = 0; i < 26; i++) { const yb = B.latest() && B.latest().you; if (!yb || !A.latest().you.al) break; // stop when A is dead too (shouldn't happen) A.send({ t: 'tp', x: yb.x - 55, y: yb.y, a: 0 }); // keep closing in on the prey await sleep(90); A.send({ t: 'in', bt: 1 }); await sleep(300); A.send({ t: 'in', bt: 0 }); } const dm = await deadP.catch(() => null); ok(dm && dm.by === 'AlphaRex', `PvP kill delivers death screen (by=${dm && dm.by})`); await sleep(500); ok(A.latest().ents.some(e => e.k === 'k'), 'carcass drops after death'); // eating from the carcass restores food const carc = A.latest().ents.find(e => e.k === 'k'); if (carc) { A.send({ t: 'tp', x: carc.x, y: carc.y, a: 0 }); const fd0 = A.latest().you.fd; A.send({ t: 'in', et: 1 }); await sleep(1800); A.send({ t: 'in', et: 0 }); ok(A.latest().you.fd > fd0, `eating carcass restores food (${fd0} -> ${A.latest().you.fd})`); } else ok(false, 'carcass present for feasting test'); // 5) respawn works on same socket const wB = B.waitFor(m => m.t === 'welcome', 6000); B.send({ t: 'join', name: 'BetaRaptor', sp: 'raptor' }); ok(await wB.then(() => true).catch(() => false), 'respawn re-joins successfully'); // 6) chat broadcast const gotChat = B.waitFor(m => m.t === 'chat' && m.from === 'AlphaRex' && m.msg === 'hello isle', 5000); A.send({ t: 'chat', msg: 'hello isle' }); ok(await gotChat.then(() => true).catch(() => false), 'chat broadcasts between players'); // 7) developer mode: activate, instant grow, spawn AI, teleport const devSnap = A.waitFor(m => m.t === 's' && m.you.dv === 1, 5000); A.send({ t: 'dev', on: 1 }); ok(await devSnap.then(() => true).catch(() => false), 'dev mode activates (you.dv=1)'); const stg0 = A.latest().you.stg; if (stg0 < 3) { A.send({ t: 'devact', act: 'grow' }); await sleep(500); ok(A.latest().you.stg === stg0 + 1, `dev grow raises stage (${stg0} -> ${A.latest().you.stg})`); } else ok(true, 'dev grow skipped (already Apex)'); A.send({ t: 'devact', act: 'spawnai' }); await sleep(800); const youA = A.latest().you; ok(A.latest().ents.some(e => e.k === 'd' && Math.hypot(e.x - youA.x, e.y - youA.y) < 900), 'dev spawnai creates AI dino nearby'); A.send({ t: 'tp', x: 3000, y: 3000 }); await sleep(400); ok(Math.abs(A.latest().you.x - 3000) < 5 && Math.abs(A.latest().you.y - 3000) < 5, 'dev Alt+Click-style tp works'); A.send({ t: 'dev', on: 0 }); await sleep(400); ok(A.latest().you.dv === 0, 'dev mode deactivates'); A.close(); B.close(); } catch (e) { ok(false, 'unexpected error: ' + e.message); } srv.kill('SIGKILL'); console.log(failures === 0 ? '\nALL SMOKE TESTS PASSED' : `\n${failures} SMOKE TEST(S) FAILED`); process.exit(failures === 0 ? 0 : 1);