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