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:
@@ -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);
|
||||
@@ -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
@@ -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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user