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
This commit is contained in:
2026-08-23 07:00:08 +00:00
commit 764da2ee6c
14 changed files with 3509 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
// Verify menu-card dino and in-game dino renderer produce matching art.
'use strict';
import { createRequire } from 'node:module';
import { readdirSync, existsSync } from 'node:fs';
const require = createRequire('/root/kidcraft/node_modules/');
const { chromium } = require('playwright-core');
function findBrowserExe() {
const root = '/root/.cache/ms-playwright';
for (const dir of readdirSync(root)) {
if (!dir.startsWith('chromium')) continue;
for (const sub of ['chrome-headless-shell-linux64/chrome-headless-shell', 'chrome-linux/headless_shell']) {
const p = `${root}/${dir}/${sub}`;
if (existsSync(p)) return p;
}
}
}
let failures = 0;
const ok = (c, l) => { console.log(`${c ? 'PASS' : 'FAIL'} ${l}`); if (!c) failures++; };
const browser = await chromium.launch({ executablePath: findBrowserExe(), args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.goto('http://127.0.0.1:8095/', { waitUntil: 'networkidle' });
await page.waitForTimeout(800);
const res = await page.evaluate(() => {
function avgColor(canvas) {
const cx = canvas.getContext('2d');
const d = cx.getImageData(0, 0, canvas.width, canvas.height).data;
let r = 0, g = 0, b = 0, n = 0;
for (let i = 0; i < d.length; i += 4) {
if (d[i + 3] < 40) continue; // skip transparent
r += d[i]; g += d[i + 1]; b += d[i + 2]; n++;
}
return n ? [r / n, g / n, b / n] : null;
}
const out = {};
// menu card canvases are the first canvas child of each .card
const cards = document.querySelectorAll('.card');
const keys = ['compy', 'raptor', 'trike', 'rex'];
cards.forEach((card, idx) => {
const cv = card.querySelector('canvas');
out[keys[idx]] = avgColor(cv);
});
// render in-game style dino for each species via shared renderer
out.game = {};
for (const k of keys) {
const cv = document.createElement('canvas');
cv.width = 180; cv.height = 88;
const c = cv.getContext('2d');
// identical backdrop as drawCardPreview
const g = c.createLinearGradient(0, 0, 0, 88);
g.addColorStop(0, 'rgba(120,190,140,0.16)');
g.addColorStop(1, 'rgba(30,60,45,0.28)');
c.fillStyle = g;
c.beginPath(); c.roundRect(0, 0, 180, 88, 8); c.fill();
c.save();
const scale = k === 'rex' ? 1.5 : k === 'compy' ? 0.95 : k === 'trike' ? 1.05 : 1.18;
c.translate(90 - 6 * scale, 88 * 0.60); c.scale(scale, scale);
paintDino(c, { x: 0, y: 4, dir: 0, r: 17, sp: k, stage: 2, phase: 1.15, moving: false, resting: false, bite: k === 'rex' ? 0.85 : 0, eat: 0, ci: 0, preview: true });
c.restore();
out.game[k] = avgColor(cv);
}
return out;
});
for (const k of ['compy', 'raptor', 'trike', 'rex']) {
const [r1, g1, b1] = res[k], [r2, g2, b2] = res.game[k];
const d = Math.hypot(r1 - r2, g1 - g2, b1 - b2);
ok(d < 12, `${k}: card vs game avg color Δ=${d.toFixed(1)} rgb(${r1.toFixed(0)},${g1.toFixed(0)},${b1.toFixed(0)}) vs (${r2.toFixed(0)},${g2.toFixed(0)},${b2.toFixed(0)})`);
}
// screenshots for human review
await page.screenshot({ path: '/tmp/shot-menu-v2.png' });
await page.fill('#nameInput', 'RexCheck');
await page.click('.card[data-key="rex"]');
await page.click('#playBtn');
await page.waitForTimeout(2200);
await page.screenshot({ path: '/tmp/shot-game-rex.png' });
await browser.close();
console.log(failures ? `\n${failures} CHECK(S) FAILED` : '\nMATCH CHECKS PASSED');
process.exit(failures ? 1 : 0);
+57
View File
@@ -0,0 +1,57 @@
// Decode screenshots in headless chromium and verify expected pixel signatures.
'use strict';
import { createRequire } from 'node:module';
import { readFileSync } from 'node:fs';
const require = createRequire('/root/kidcraft/node_modules/');
const { chromium } = require('playwright-core');
const EXE = '/root/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell';
let failures = 0;
const ok = (c, l) => { console.log(`${c ? 'PASS' : 'FAIL'} ${l}`); if (!c) failures++; };
async function stats(page, path, x0, y0, x1, y1) {
const b64 = readFileSync(path).toString('base64');
return await page.evaluate(async ({ b64, x0, y0, x1, y1 }) => {
const img = new Image();
img.src = 'data:image/png;base64,' + b64;
await new Promise(r => { img.onload = r; });
const cv = document.createElement('canvas');
cv.width = img.width; cv.height = img.height;
const cx = cv.getContext('2d');
cx.drawImage(img, 0, 0);
const d = cx.getImageData(x0, y0, x1 - x0, y1 - y0).data;
let blue = 0, green = 0, tan = 0, red = 0, dark = 0, n = 0;
for (let i = 0; i < d.length; i += 4) {
const [r, g, b] = [d[i], d[i + 1], d[i + 2]]; n++;
if (b > r + 20 && b > g + 10) blue++;
else if (g > r + 15 && g > b + 15) green++;
else if (r > 150 && g > 130 && b < g) tan++;
else if (r > 140 && r > g + 50 && r > b + 50) red++;
if (r + g + b < 120) dark++;
}
return { w: img.width, h: img.height, frac: { blue: blue / n, green: green / n, tan: tan / n, red: red / n, dark: dark / n } };
}, { b64, x0, y0, x1, y1 });
}
const browser = await chromium.launch({ executablePath: EXE, args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.goto('about:blank');
// ---- menu ----
const menu = await stats(page, '/tmp/shot-menu.png', 0, 0, 1280, 800);
ok(menu.w === 1280 && menu.h === 800, `menu screenshot size ${menu.w}x${menu.h}`);
ok(menu.frac.dark < 0.98, 'menu is not a black screen');
// ---- gameplay full frame ----
const game = await stats(page, '/tmp/shot-game-a.png', 0, 0, 1280, 800);
ok(game.frac.blue > 0.05 || game.frac.green > 0.05, `island terrain painted (blue ${game.frac.blue.toFixed(2)}, green ${game.frac.green.toFixed(2)})`);
// bottom-left vitals region should contain red-ish HP fill
const vitals = await stats(page, '/tmp/shot-game-a.png', 16, 800 - 160, 256, 790);
ok(vitals.frac.red > 0.02, `HP bar red present (${vitals.frac.red.toFixed(3)})`);
// top-right minimap region not empty
const mm = await stats(page, '/tmp/shot-game-a.png', 1116, 14, 1266, 178);
ok(mm.frac.dark < 0.95 && (mm.frac.blue > 0.03 || mm.frac.green > 0.03), 'minimap rendered island');
await browser.close();
console.log(failures === 0 ? '\nPIXEL CHECKS PASSED' : `\n${failures} PIXEL CHECK(S) FAILED`);
process.exit(failures ? 1 : 0);
+230
View File
@@ -0,0 +1,230 @@
// 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);
+84
View File
@@ -0,0 +1,84 @@
// Visual verification: load the real game in headless Chromium, capture
// console/page errors, join with two players, screenshot menu + gameplay.
'use strict';
import { createRequire } from 'node:module';
import { readdirSync, existsSync } from 'node:fs';
const require = createRequire('/root/kidcraft/node_modules/');
const { chromium } = require('playwright-core');
// auto-discover the headless shell binary (cache version changes over time)
function findBrowserExe() {
const root = '/root/.cache/ms-playwright';
try {
for (const dir of readdirSync(root)) {
if (!dir.startsWith('chromium')) continue;
for (const sub of ['chrome-headless-shell-linux64/chrome-headless-shell', 'chrome-linux/headless_shell', 'chrome-linux/chrome']) {
const p = `${root}/${dir}/${sub}`;
if (existsSync(p)) return p;
}
}
} catch {}
return null;
}
const EXE = findBrowserExe();
if (!EXE) { console.log('FAIL no chromium binary found in ms-playwright cache'); process.exit(1); }
const URL = 'http://127.0.0.1:8095/';
let failures = 0;
const ok = (c, l) => { console.log(`${c ? 'PASS' : 'FAIL'} ${l}`); if (!c) failures++; };
const browser = await chromium.launch({ executablePath: EXE, args: ['--no-sandbox'] });
const ctxA = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const pageA = await ctxA.newPage();
const errors = [];
pageA.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
pageA.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
await pageA.goto(URL, { waitUntil: 'networkidle' });
await pageA.waitForTimeout(1200);
ok((await pageA.title()) === 'Dino Isle Online', 'page title loads');
ok(await pageA.isVisible('#menu'), 'menu overlay visible');
const cardCount = await pageA.locator('.card').count();
ok(cardCount === 4, `4 species cards rendered (got ${cardCount})`);
await pageA.screenshot({ path: '/tmp/shot-menu.png' });
// join as player A
await pageA.fill('#nameInput', 'VisRex');
await pageA.click('.card[data-key="rex"]');
await pageA.click('#playBtn');
await pageA.waitForTimeout(2500);
ok(await pageA.isHidden('#menu'), 'menu hides after Play');
ok(!(await pageA.evaluate(() => document.getElementById('hud').classList.contains('hidden'))), 'HUD visible in game');
// canvas actually drawing? sample a pixel region via 2d readback
const painted = await pageA.evaluate(() => {
const cv = document.getElementById('game');
const c = document.createElement('canvas');
c.width = cv.width; c.height = cv.height;
c.getContext('2d').drawImage(cv, 0, 0);
const d = c.getContext('2d').getImageData(c.width >> 1, c.height >> 1, 1, 1).data;
return d[0] + d[1] + d[2] > 0;
});
ok(painted, 'game canvas has painted pixels');
await pageA.screenshot({ path: '/tmp/shot-game-a.png' });
// second player joins on another "tab"
const pageB = await (await browser.newContext({ viewport: { width: 1100, height: 700 } })).newPage();
pageB.on('pageerror', (e) => errors.push('B pageerror: ' + e.message));
await pageB.goto(URL, { waitUntil: 'networkidle' });
await pageB.fill('#nameInput', 'VisRaptor');
await pageB.click('.card[data-key="raptor"]');
await pageB.click('#playBtn');
await pageB.waitForTimeout(2000);
// A walks a bit & bites; then screenshot again (multiplayer HUD state)
for (const key of ['KeyW', 'KeyW', 'Space']) { await pageA.keyboard.press(key); await pageA.waitForTimeout(350); }
await pageA.waitForTimeout(1200);
const online = await pageA.textContent('#onlineCount');
ok(parseInt(online) >= 2, `server reports ${online} players online`);
await pageA.screenshot({ path: '/tmp/shot-game-mp.png' });
ok(errors.length === 0, 'no browser console/page errors' + (errors.length ? ' -> ' + errors.slice(0, 4).join(' | ') : ''));
await browser.close();
console.log(failures === 0 ? '\nALL VISUAL TESTS PASSED' : `\n${failures} VISUAL TEST(S) FAILED`);
process.exit(failures ? 1 : 0);