Files
dino-isle-online/test/smoke.mjs
T
deepseek 764da2ee6c Dino Isle Online: multiplayer dino survival game (The Isle-like)
- Zero-dependency Node server: HTTP static + hand-rolled RFC6455 WebSocket + 20Hz authoritative simulation
- Seeded island worldgen (tiles, forest decor, spawns)
- 4 playable species, needs (food/water/stamina), rest, growth stages Hatchling->Apex
- AI fauna: critters, fish shoals, Dryosaurus/Psittacosaurus herds; carcass feasting
- PvP with knockback, kill feed, chat, leaderboard, minimap, day/night cycle
- True top-down procedural dino renderer shared by menu cards and in-game
- Developer mode (` key): god-mode, x6 XP, instant evolve, spawn AI, Alt+Click tp
- Headless smoke tests, real-browser visual tests, pixel match checks
2026-08-23 07:00:08 +00:00

231 lines
8.7 KiB
JavaScript

// 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);