AIArchives / archives /neonstrike /test /playtest.mjs
CompactAI's picture
Upload 126 files
f6b8770 verified
Raw
History Blame Contribute Delete
24.1 kB
// NEONSTRIKE automated playtest. Headless Chrome (SwiftShader WebGL2):
// 1. boot + zero console/page errors
// 2. screenshot menu and several in-game vantage points; verify pixels are
// actually rendered (variance, not black, sky vs floor differ)
// 3. material swatch verification for every procedural texture
// 4. SFX verification: every generated buffer has sane duration/peak/RMS
// 5. gameplay: fire at bot -> damage/kill/money; round flow; bot AI moves
// 6. settings: bounces visibly change the image, persistence to localStorage
// 7. perf snapshot per quality preset
// Artifacts land in ../playtest/ (screenshots + report.md).
import puppeteer from 'puppeteer-core';
import { spawn } from 'child_process';
import http from 'http';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { PNG } from './png.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const APP_DIR = path.join(__dirname, '..', 'app');
const OUT_DIR = path.join(__dirname, '..', 'playtest');
fs.mkdirSync(OUT_DIR, { recursive: true });
const CHROME = ['/usr/bin/google-chrome-stable', '/usr/bin/google-chrome', '/usr/bin/chromium-browser']
.find(p => fs.existsSync(p));
// run the REAL game server (static + websocket rooms)
function serve() {
const port = 3900 + Math.floor(Math.random() * 90);
const proc = spawn('node', ['server/index.js'], {
cwd: APP_DIR,
env: { ...process.env, PORT: String(port) },
stdio: 'pipe',
});
proc.stderr.on('data', d => process.stderr.write(`[server] ${d}`));
return new Promise((resolve, reject) => {
const t0 = Date.now();
const poll = () => {
http.get(`http://127.0.0.1:${port}/`, res => resolve({ proc, port, close: () => proc.kill() }))
.on('error', () => {
if (Date.now() - t0 > 8000) reject(new Error('server did not start'));
else setTimeout(poll, 150);
});
};
setTimeout(poll, 300);
});
}
const results = [];
let failures = 0;
function check(name, ok, detail = '') {
results.push({ name, ok, detail });
if (!ok) failures++;
console.log(`${ok ? ' ✔' : ' ✘'} ${name}${detail ? ` — ${detail}` : ''}`);
}
function pngStats(buf, region) {
const png = PNG.read(buf);
const { width, height, pixels } = png;
const [x0, y0, x1, y1] = region || [0, 0, width, height];
let n = 0, mean = [0, 0, 0], m2 = 0, black = 0;
for (let y = y0; y < y1; y += 2) {
for (let x = x0; x < x1; x += 2) {
const i = (y * width + x) * 4;
const r = pixels[i], g = pixels[i + 1], b = pixels[i + 2];
mean[0] += r; mean[1] += g; mean[2] += b;
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
m2 += lum * lum;
if (lum < 6) black++;
n++;
}
}
mean = mean.map(v => v / n);
const lumMean = 0.299 * mean[0] + 0.587 * mean[1] + 0.114 * mean[2];
const variance = m2 / n - lumMean * lumMean;
return { mean, lumMean, std: Math.sqrt(Math.max(0, variance)), blackFrac: black / n, width, height };
}
function diffFrac(bufA, bufB) {
const a = PNG.read(bufA), b = PNG.read(bufB);
if (a.width !== b.width || a.height !== b.height) return 1;
let diff = 0, n = 0;
for (let i = 0; i < a.pixels.length; i += 8) {
const d = Math.abs(a.pixels[i] - b.pixels[i]) + Math.abs(a.pixels[i + 1] - b.pixels[i + 1]) + Math.abs(a.pixels[i + 2] - b.pixels[i + 2]);
if (d > 24) diff++;
n++;
}
return diff / n;
}
async function shot(page, name) {
const buf = await page.screenshot({ path: path.join(OUT_DIR, name + '.png') });
return buf;
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
const srv = await serve();
const PORT = srv.port;
console.log(`\nNEONSTRIKE playtest — real game server on :${PORT}, chrome: ${CHROME}\n`);
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: 'new',
args: [
'--no-sandbox', '--disable-dev-shm-usage',
'--use-gl=angle', '--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--autoplay-policy=no-user-gesture-required',
'--window-size=1280,720',
],
defaultViewport: { width: 1280, height: 720 },
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', e => errors.push('pageerror: ' + e.message));
page.on('console', m => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
// ---------- 1. boot ----------
console.log('[1] boot');
await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'networkidle0' });
await page.waitForFunction('window.GAME && window.GAME.ready', { timeout: 15000 });
await sleep(1200); // let a few frames render
check('page boots, GAME api ready', true);
check('zero JS errors at boot', errors.length === 0, errors.join(' | ').slice(0, 300));
const menuShot = await shot(page, '01-menu');
let s = pngStats(menuShot);
check('menu renders (not black)', s.blackFrac < 0.7 && s.std > 8, `std=${s.std.toFixed(1)} black=${(s.blackFrac * 100).toFixed(0)}%`);
// ---------- 2. start + vantage screenshots ----------
console.log('[2] in-game render');
await page.evaluate(() => GAME.test.start());
await page.waitForFunction('GAME.test.state().gameState !== "menu"');
await page.evaluate(() => GAME.test.skipBuy());
await sleep(700);
const vantages = [
['02-spawn', 0, 0, 27, 0, 0],
['03-mid', 0, 0, 8, 0, -0.05],
['04-asite', 20, 0, -12, 1.2, -0.05],
['05-bsite', -20, 0, -12, -1.2, -0.05],
['06-sky', 0, 0, 10, 0, 1.0],
];
const vshots = {};
for (const [name, x, y, z, yaw, pitch] of vantages) {
await page.evaluate((x, y, z, yaw, pitch) => GAME.test.pose(x, y, z, yaw, pitch), x, y, z, yaw, pitch);
await sleep(250);
vshots[name] = await shot(page, name);
const st = pngStats(vshots[name]);
check(`${name} renders`, st.blackFrac < 0.85 && st.std > 6, `std=${st.std.toFixed(1)} black=${(st.blackFrac * 100).toFixed(0)}%`);
}
// sky vs ground sanity: looking up differs from looking ahead
check('sky view differs from spawn view', diffFrac(vshots['06-sky'], vshots['02-spawn']) > 0.25,
`diff=${(diffFrac(vshots['06-sky'], vshots['02-spawn']) * 100).toFixed(0)}%`);
// character close-up: stand a bot in front of the camera
await page.evaluate(() => {
const g = GAME.game;
const b = g.bots[0];
b.pos = [0, 0, 18];
b.yaw = Math.PI; // face the camera
GAME.test.pose(0, 0, 21.5, 0, 0.05);
});
await sleep(250);
const charShot = await shot(page, '06b-character');
// visor should put hot/red-ish emissive pixels near center
{
const st = pngStats(charShot, [500, 180, 780, 460]);
check('character close-up renders (humanoid, lit)', st.std > 12 && st.blackFrac < 0.6,
`std=${st.std.toFixed(1)}`);
}
// ---------- 2b. map diversity ----------
console.log('[2b] map diversity');
const mapShots = {};
for (const mapId of await page.evaluate(() => GAME.maps)) {
await page.evaluate(id => GAME.test.start(id), mapId);
await page.evaluate(() => GAME.test.skipBuy());
await sleep(500);
// look around from spawn at slight downward pitch
await page.evaluate(() => {
const sp = GAME.game.map.PLAYER_SPAWN;
GAME.test.pose(sp.x, sp.y + 0.01, sp.z, sp.yaw, -0.05);
});
await sleep(300);
mapShots[mapId] = await shot(page, `map-${mapId}`);
const st = pngStats(mapShots[mapId]);
check(`map "${mapId}" renders`, st.blackFrac < 0.7 && st.std > 8,
`std=${st.std.toFixed(1)} black=${(st.blackFrac * 100).toFixed(0)}%`);
}
{
const ids = Object.keys(mapShots);
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const d = diffFrac(mapShots[ids[i]], mapShots[ids[j]]);
check(`maps ${ids[i]} vs ${ids[j]} visually distinct`, d > 0.3, `${(d * 100).toFixed(0)}% pixels differ`);
}
}
}
// logic-only from here: render cheap so sim time tracks wall clock
await page.evaluate(() => GAME.test.setSetting('graphics.preset', 'potato'));
// bots navigate on each new map (sim-time budgeted)
for (const mapId of ['foundry', 'canopy', 'bastion']) {
const nav = await page.evaluate(async id => {
GAME.test.start(id);
GAME.test.skipBuy();
const sp = GAME.game.map.PLAYER_SPAWN;
GAME.test.pose(sp.x, 0, sp.z, 0, 0);
GAME.game.player.hp = 0; // invisible to bot vision: measure pure patrol
const a = GAME.test.botInfo().map(b => [...b.pos]);
const t0 = GAME.game.time;
const w0 = Date.now();
while (GAME.game.time - t0 < 3 && Date.now() - w0 < 60000) await new Promise(r => setTimeout(r, 200));
const b = GAME.test.botInfo().map(x => [...x.pos]);
let moved = 0;
for (let i = 0; i < a.length; i++) {
if (Math.hypot(a[i][0] - b[i][0], a[i][2] - b[i][2]) > 0.5) moved++;
}
const st = GAME.test.state();
return { moved, total: a.length, simAdvanced: +(GAME.game.time - t0).toFixed(2), screen: st.screen, state: st.gameState, fps: st.fps };
}, mapId);
check(`bots navigate "${mapId}"`, nav.moved >= 1, JSON.stringify(nav));
}
// back to vector for the remaining sections
await page.evaluate(() => { GAME.test.start('vector'); GAME.test.skipBuy(); });
await sleep(400);
// ---------- 3. material swatches ----------
console.log('[3] textures');
const matNames = await page.evaluate(() => GAME.materialNames);
const matStats = [];
for (let i = 0; i < matNames.length; i++) {
await page.evaluate(id => GAME.test.materialView(id), i);
await sleep(150);
const buf = await shot(page, `mat-${String(i).padStart(2, '0')}-${matNames[i].replace(/\s+/g, '_')}`);
const st = pngStats(buf);
matStats.push(st);
const textured = st.std > 2.5 || st.lumMean > 30; // emissives may be flat but bright
check(`texture "${matNames[i]}" produces pixels`, st.blackFrac < 0.5 && textured,
`mean=${st.lumMean.toFixed(0)} std=${st.std.toFixed(1)}`);
}
// materials must be distinguishable from each other
let distinct = 0;
for (let i = 1; i < matStats.length; i++) {
const a = matStats[i - 1], b = matStats[i];
const d = Math.abs(a.mean[0] - b.mean[0]) + Math.abs(a.mean[1] - b.mean[1]) + Math.abs(a.mean[2] - b.mean[2]);
if (d > 12) distinct++;
}
check('adjacent materials visually distinct', distinct >= matStats.length - 3, `${distinct}/${matStats.length - 1} pairs`);
await page.evaluate(() => GAME.test.materialView(-1));
// ---------- 4. SFX ----------
console.log('[4] sfx');
const sfx = await page.evaluate(() => GAME.test.sfxReport());
const sfxRows = [];
for (const [name, st] of Object.entries(sfx)) {
const ok = st.seconds > 0.05 && st.peak > 0.2 && st.peak <= 1.0 && st.rms > 0.005;
check(`sfx "${name}"`, ok, `dur=${st.seconds}s peak=${st.peak} rms=${st.rms}`);
sfxRows.push({ name, ...st });
}
// ---------- 5. gameplay ----------
console.log('[5] gameplay');
// stand right next to a patrolling bot: it must spot and shoot us.
// Budgeted in SIM time — wall-clock is meaningless at software-render fps.
const engaged = await page.evaluate(async () => {
GAME.test.start('vector');
GAME.test.skipBuy();
let hits = 0;
const orig = GAME.game.events.onPlayerDamage;
GAME.game.events.onPlayerDamage = (...a) => { hits++; orig(...a); };
await new Promise(r => setTimeout(r, 200));
const t0 = GAME.game.time;
const w0 = Date.now();
while (GAME.game.time - t0 < 12 && Date.now() - w0 < 90000 && hits === 0) {
const b = GAME.test.botInfo()[0];
GAME.test.pose(b.pos[0] + 2.5, 0, b.pos[2] + 1.5, 0, 0); // <4u: in vision radius
await new Promise(r => setTimeout(r, 150));
}
GAME.game.events.onPlayerDamage = orig;
return { hits, simT: +(GAME.game.time - t0).toFixed(1) };
});
check('bot AI spots and engages exposed player', engaged.hits > 0, `${engaged.hits} hits taken in ${engaged.simT} sim-s`);
// fresh match for deterministic gameplay checks
await page.evaluate(() => { GAME.test.start(); GAME.test.skipBuy(); });
await sleep(300);
const st0 = await page.evaluate(() => GAME.test.state());
check('bots spawned', st0.botsAlive >= 1, `${st0.botsAlive} alive`);
check('fresh match: full hp + $800', st0.hp === 100 && st0.money === 800, `hp=${st0.hp} money=${st0.money}`);
// teleport next to bot 0 at a spot with verified line of sight, re-aim
// before every shot (bots patrol while we shoot)
const fireResult = await page.evaluate(async () => {
const before = GAME.test.state();
const bot0hp = GAME.test.botInfo()[0].hp;
const poseWithLOS = () => {
const g = GAME.game;
const bot = g.bots[0];
const head = bot.headPos();
for (const [dx, dz] of [[0, 4], [0, -4], [4, 0], [-4, 0], [3, 3], [-3, -3], [0, 2]]) {
const eye = [head[0] + dx, 1.62, head[2] + dz];
if (g.hasLOS(eye, head)) {
GAME.test.pose(eye[0], 0, eye[2], 0, 0);
return true;
}
}
return false;
};
for (let i = 0; i < 6; i++) {
if (!poseWithLOS()) continue;
GAME.test.aimAtBot(0);
GAME.game.player.fireCd = 0; // bypass rpm for the test
GAME.test.fire();
await new Promise(r => setTimeout(r, 30));
}
const after = GAME.test.state();
return { ammoBefore: before.ammo, ammoAfter: after.ammo, botHpBefore: bot0hp, botHpAfter: GAME.test.botInfo()[0].hp };
});
check('firing consumes ammo', fireResult.ammoAfter < fireResult.ammoBefore,
`${fireResult.ammoBefore} -> ${fireResult.ammoAfter}`);
check('hitscan damages bot at close range', fireResult.botHpAfter < fireResult.botHpBefore,
`bot hp ${fireResult.botHpBefore} -> ${fireResult.botHpAfter}`);
// bot AI movement over SIM time (wall-clock waits race at software fps)
const move = await page.evaluate(async () => {
GAME.test.pose(0, 0, 27, 0, 0);
const a = GAME.test.botInfo().map(b => [...b.pos]);
const t0 = GAME.game.time;
const w0 = Date.now();
while (GAME.game.time - t0 < 3 && Date.now() - w0 < 60000) await new Promise(r => setTimeout(r, 200));
const b = GAME.test.botInfo().map(x => [...x.pos]);
let moved = 0;
for (let i = 0; i < a.length; i++) {
const d = Math.hypot(a[i][0] - b[i][0], a[i][2] - b[i][2]);
if (d > 0.5) moved++;
}
return { moved, total: a.length };
});
check('bot AI navigates the map', move.moved >= 1, `${move.moved}/${move.total} bots moved`);
// kill all bots -> round won, money paid
const round = await page.evaluate(async () => {
const before = GAME.test.state();
const n = GAME.test.botInfo().length;
for (let i = 0; i < n; i++) GAME.test.damageBot(i, 500, true);
await new Promise(r => setTimeout(r, 200));
const after = GAME.test.state();
return { before, after };
});
check('killing all bots ends round in player win',
round.after.score.player === round.before.score.player + 1,
`score ${round.before.score.player} -> ${round.after.score.player}`);
check('kill + win money awarded', round.after.money > round.before.money,
`$${round.before.money} -> $${round.after.money}`);
// buy flow next round — wait on the state machine, not wall-clock (sim time
// runs slower than real time under software rendering)
await page.waitForFunction('GAME.test.state().gameState === "buy"', { timeout: 60000 });
const buy = await page.evaluate(() => {
const moneyBefore = GAME.test.state().money;
const bought = GAME.test.buy('rifle');
const after = GAME.test.state();
return { bought, moneyBefore, moneyAfter: after.money, weapon: after.weapon };
});
check('next round enters buy phase', true, 'reached via state machine');
check('buying rifle works + deducts $2700', buy.bought && buy.moneyAfter === buy.moneyBefore - 2700 && buy.weapon === 'rifle',
`$${buy.moneyBefore} -> $${buy.moneyAfter}, weapon=${buy.weapon}`);
const hudShot = await shot(page, '07-hud-buyphase');
s = pngStats(hudShot);
check('HUD + buy menu render', s.std > 10, `std=${s.std.toFixed(1)}`);
// ---------- 6. settings ----------
console.log('[6] settings');
await page.evaluate(() => { GAME.test.skipBuy(); GAME.test.pose(0, 0, 8, 0, -0.05); });
await sleep(300);
await page.evaluate(() => GAME.test.setSetting('graphics.rayBounces', 0));
await sleep(300);
const noBounce = await shot(page, '08-bounces-0');
await page.evaluate(() => GAME.test.setSetting('graphics.rayBounces', 3));
await sleep(300);
const fullBounce = await shot(page, '09-bounces-3');
const bounceDiff = diffFrac(noBounce, fullBounce);
check('ray bounces visibly change reflections', bounceDiff > 0.01, `${(bounceDiff * 100).toFixed(1)}% pixels changed`);
await page.evaluate(() => GAME.test.setSetting('graphics.resolutionScale', 0.4));
// wait for a frame to actually render at the new scale (software fps is low)
await page.waitForFunction('GAME.test.state().canvas.w === Math.round(1280 * 0.4)', { timeout: 15000 }).catch(() => {});
const lowRes = await page.evaluate(() => GAME.test.state().canvas);
check('resolution scale resizes RT target', lowRes.w === Math.round(1280 * 0.4), `${lowRes.w}x${lowRes.h}`);
const persisted = await page.evaluate(() => {
GAME.test.setSetting('controls.sensitivity', 2.35);
return JSON.parse(localStorage.getItem('neonstrike.settings.v1')).controls.sensitivity;
});
check('settings persist to localStorage', persisted === 2.35, `saved=${persisted}`);
// ---------- 7. perf ----------
console.log('[7] perf (SwiftShader software rendering — real GPUs are far faster)');
const perfRows = [];
for (const preset of ['potato', 'balanced', 'ultra']) {
await page.evaluate(p => GAME.test.setSetting('graphics.preset', p), preset);
await sleep(1600);
const fps = await page.evaluate(() => GAME.test.state().fps);
perfRows.push({ preset, fps });
check(`preset "${preset}" renders frames`, fps > 0, `${fps} fps (software)`);
}
// ---------- 7b. visual upgrades ----------
console.log('[7b] visual upgrades (Blender sprites, bloom, limbs)');
// weapon sprite + diorama assets exist and serve
for (const a of ['weapons/rifle_vm.png', 'weapons/knife_card.png', 'maps/foundry.png']) {
const code = await new Promise(res => {
http.get(`http://127.0.0.1:${PORT}/assets/${a}`, r => { r.resume(); res(r.statusCode); }).on('error', () => res(0));
});
check(`asset ${a} serves`, code === 200, `http ${code}`);
}
const spritesLoaded = await page.evaluate(() =>
Object.keys(GAME.test.vmSprites ? GAME.test.vmSprites() : {}).length);
check('viewmodel sprites decoded in browser', spritesLoaded >= 7, `${spritesLoaded}/7`);
// bloom visibly changes the frame
await page.evaluate(() => {
GAME.test.setSetting('graphics.resolutionScale', 0.75);
GAME.test.start('vector'); GAME.test.skipBuy(); GAME.test.pose(0, 0, 10, 0, -0.05);
});
await sleep(300);
await page.evaluate(() => GAME.test.setSetting('graphics.bloom', 'off'));
await sleep(300);
const bloomOff = await shot(page, '11-bloom-off');
await page.evaluate(() => GAME.test.setSetting('graphics.bloom', 'high'));
await sleep(300);
const bloomOn = await shot(page, '12-bloom-high');
const bloomDiff = diffFrac(bloomOff, bloomOn);
check('HDR bloom visibly changes pixels', bloomDiff > 0.02, `${(bloomDiff * 100).toFixed(1)}% changed`);
// walk-cycle: a moving bot's rig changes pose between frames
const limbCheck = await page.evaluate(async () => {
const b = GAME.game.bots[0];
b.walkPhase = 0; b.moving = 1;
const rigA = JSON.stringify(GAME.game.renderBodies().capsules.slice(0, 6));
b.walkPhase = Math.PI / 2;
const rigB = JSON.stringify(GAME.game.renderBodies().capsules.slice(0, 6));
return { differs: rigA !== rigB, caps: GAME.game.renderBodies().capsules.length };
});
check('character limbs exist as capsules', limbCheck.caps >= 6, `${limbCheck.caps} capsules`);
check('walk cycle animates limb pose', limbCheck.differs, '');
// ---------- 8. multiplayer ----------
console.log('[8] multiplayer (two real browser clients)');
const pageB = await browser.newPage();
pageB.on('pageerror', e => errors.push('B pageerror: ' + e.message));
pageB.on('console', m => { if (m.type() === 'error') errors.push('B console: ' + m.text()); });
await pageB.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: 'networkidle0' });
await pageB.waitForFunction('window.GAME && window.GAME.ready');
const roomA = await page.evaluate(() => GAME.test.startDM('ALICE', { map: 'vector' }));
check('client A creates room, gets 4-char code', /^[A-Z2-9]{4}$/.test(roomA.code), `code=${roomA.code}`);
const roomB = await pageB.evaluate(code => GAME.test.startDM('BOB', { join: code }), roomA.code);
check('client B joins via room code', roomB.code === roomA.code, `joined ${roomB.code}`);
await page.evaluate(() => GAME.test.skipBuy());
await pageB.evaluate(() => GAME.test.skipBuy());
// park both in the open mid-courtyard with clear LOS, let state sync
await page.evaluate(() => GAME.test.pose(0, 0, 24, 0, 0));
await pageB.evaluate(() => GAME.test.pose(0, 0, 14, Math.PI, 0));
let seenA = 0, seenB = 0;
for (let i = 0; i < 40 && !(seenA === 1 && seenB === 1); i++) {
await sleep(200);
seenA = await page.evaluate(() => GAME.test.state().remoteCount);
seenB = await pageB.evaluate(() => GAME.test.state().remoteCount);
}
check('both clients see each other', seenA === 1 && seenB === 1, `A sees ${seenA}, B sees ${seenB}`);
const mpShot = await shot(page, '10-multiplayer');
{
const st = pngStats(mpShot, [480, 200, 800, 500]);
check('remote player renders in A\'s view', st.std > 10, `std=${st.std.toFixed(1)}`);
}
// A shoots B until the server confirms damage
const dmg = await page.evaluate(async () => {
for (let i = 0; i < 30; i++) {
const r = GAME.game.remoteTargets[0];
if (r) {
GAME.test.aimAt(r.x, r.y + 1.0, r.z);
GAME.game.player.fireCd = 0;
GAME.test.fire();
}
await new Promise(res => setTimeout(res, 60));
const remote = GAME.getNet().remotePlayers.values().next().value;
if (remote && remote.hp < 100) return { hp: remote.hp, shots: i + 1 };
}
return { hp: 100, shots: 30 };
});
const hpB = await pageB.evaluate(() => GAME.test.state().hp);
check('cross-client hit: server applies damage', dmg.hp < 100 && hpB < 100,
`B hp=${hpB} (A sees ${dmg.hp}) after ${dmg.shots} shots`);
// finish the kill
await page.evaluate(async () => {
for (let i = 0; i < 60; i++) {
const r = GAME.game.remoteTargets[0];
if (!r) break;
GAME.test.aimAt(r.x, r.y + 1.0, r.z);
GAME.game.player.fireCd = 0;
GAME.test.fire();
await new Promise(res => setTimeout(res, 50));
}
});
await sleep(500);
const deadB = await pageB.evaluate(() => GAME.test.state());
check('kill: victim enters dead state', deadB.gameState === 'dead' || deadB.hp === 0,
`B state=${deadB.gameState} hp=${deadB.hp}`);
const scoresA = await page.evaluate(() => GAME.test.scores());
const alice = scoresA.find(s => s.name === 'ALICE');
check('scoreboard credits the kill', alice && alice.kills >= 1, JSON.stringify(scoresA));
// respawn flow
await sleep(3200);
await pageB.evaluate(() => { GAME.getNet().sendRespawn(); });
await sleep(600);
const respB = await pageB.evaluate(() => GAME.test.state());
check('victim respawns at full hp', respB.hp === 100 && respB.gameState === 'live',
`hp=${respB.hp} state=${respB.gameState}`);
await pageB.close();
check('zero JS errors across entire playtest', errors.length === 0, errors.join(' | ').slice(0, 400));
// ---------- report ----------
const report = [
'# NEONSTRIKE — automated playtest report',
'',
`Date: ${new Date().toISOString()}`,
`Renderer: headless Chrome + SwiftShader (software WebGL2). FPS numbers are a software-rasterizer floor, not GPU performance.`,
'',
`## Results: ${results.filter(r => r.ok).length}/${results.length} passed`,
'',
...results.map(r => `- [${r.ok ? 'x' : ' '}] ${r.name}${r.detail ? ` — ${r.detail}` : ''}`),
'',
'## SFX verification',
'',
'| sound | duration | peak | rms |',
'|---|---|---|---|',
...sfxRows.map(r => `| ${r.name} | ${r.seconds}s | ${r.peak} | ${r.rms} |`),
'',
'## Perf (software rendering floor)',
'',
'| preset | fps |',
'|---|---|',
...perfRows.map(r => `| ${r.preset} | ${r.fps} |`),
'',
'## Screenshots',
'',
...fs.readdirSync(OUT_DIR).filter(f => f.endsWith('.png')).sort().map(f => `- ${f}`),
].join('\n');
fs.writeFileSync(path.join(OUT_DIR, 'report.md'), report);
await browser.close();
srv.close();
console.log(`\n${results.filter(r => r.ok).length}/${results.length} checks passed. Report: playtest/report.md`);
process.exit(failures ? 1 : 0);