Spaces:
Running
Running
| // 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); | |