/** * Dev-only CDP screenshot harness for the LEVEL-UP MARQUEE — drives the * real game (index.html?combat) and plays the marquee's beats through the * production component (GameScene.levelMarquee, js/ui/LevelUpMarquee.js), * capturing one labeled frame per beat phase so the full sequence is on * record. * * node dev/marquee-shot.mjs [outDir] * * Requires the dev server (http://127.0.0.1:8080) and a headless * Chromium-CDP browser on 127.0.0.1:9333 (same as dev/cdp-shot.mjs): * /opt/brave.com/brave/brave --headless --disable-gpu \ * --remote-debugging-port=9333 about:blank * * Outputs (committed as evidence under the given outDir, default * dev/shots/) — the beat plays 7 → 9 (+2 LEVELS), then 78 → 80 (the * FIRMWARE-MAXED branch): * marquee-old.png — the old level booted in (decode line live) * marquee-shatter.png — corruption: slices + the shatter into shards * marquee-scramble.png — the new level's digit slots cycling/locking * marquee-lock.png — the lock-in: flash, shockwave, sparks * marquee-sync.png — the hold: SYNC COMPLETE ▸ +2 LEVELS + armor * marquee-peel.png — the neon shutter peeling the frame away * marquee-max.png — the cap: FIRMWARE MAXED ▸ LEVEL 80 */ const fs = await import('node:fs'); const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333'; const BASE = process.env.BASE ?? 'http://127.0.0.1:8080'; const OUT = process.argv[2] ?? 'dev/shots'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function main() { fs.mkdirSync(OUT, { recursive: true }); async function openPage(url) { let target; try { target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); } catch { target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json(); } const ws = new WebSocket(target.webSocketDebuggerUrl); let id = 0; const pending = new Map(); const send = (method, params = {}) => new Promise((res, rej) => { const mid = ++id; pending.set(mid, { res, rej }); ws.send(JSON.stringify({ id: mid, method, params })); }); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = () => rej(new Error('ws error')); }); ws.onmessage = (m) => { const x = JSON.parse(m.data); if (x.id && pending.has(x.id)) { const { res, rej } = pending.get(x.id); pending.delete(x.id); x.error ? rej(new Error(x.error.message)) : res(x.result); } }; await send('Runtime.enable'); await send('Page.enable'); await send('Network.enable'); await send('Network.setCacheDisabled', { cacheDisabled: true }); await send('Page.navigate', { url }); const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error('page: ' + JSON.stringify(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)); return r.result?.value; }; const waitTrue = async (expr, timeoutMs = 45000) => { const end = Date.now() + timeoutMs; while (Date.now() < end) { const v = await ev(expr).catch(() => null); if (v) return v; await sleep(250); } throw new Error('timed out waiting for: ' + expr.slice(0, 60)); }; const shot = async (path, delayMs = 90) => { await sleep(delayMs); // let the last paint land const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path, Buffer.from(r.data, 'base64')); console.log(` shot: ${path}`); }; const close = async () => { await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); ws.close(); }; return { ev, waitTrue, shot, close, target }; } const p = await openPage(`${BASE}/index.html?combat`); try { await p.waitTrue('Boolean(window.game && window.game.scene.getScene("MenuScene") && window.game.scene.getScene("MenuScene").seedValue)'); await p.ev('window.game.scene.getScene("MenuScene").startNewGame()'); await p.waitTrue('Boolean(window.__ORBIT_COMBAT__)'); // The new-run tutorial comms own the screen (ack pause) — clear the // chain through the hub's own finish path so the world behind the // marquee is the live game, not a comm panel. await p.ev(`(() => { const g = window.game.scene.getScene('GameScene'); const h = g.comms; if (!h) return true; if (h.current) { try { h._finish(); } catch (e) {} } if (Array.isArray(h.queue)) h.queue.length = 0; h._setPaused(false); try { h.box?.close(); } catch (e) {} h.box = null; return true; })()`); // The camera follows the ship; wait until it is centered on it. await p.waitTrue(`(() => { const g = window.game.scene.getScene('GameScene'); const cam = g.cameras.main; const w = g.scale.width, h = g.scale.height; const cx = (cam.worldView?.x ?? cam.scrollX ?? cam.x) + w / 2; const cy = (cam.worldView?.y ?? cam.scrollY ?? cam.y) + h / 2; return Math.abs(cx - g.ship.x) < 24 && Math.abs(cy - g.ship.y) < 24; })()`, 8000).catch(() => null); await p.ev('document.fonts.ready.then(() => true)'); await sleep(400); // the pack settles; the sim ticks // Play the beat through the production component (the exact path a // real kill's XP grant drives — the ledger's event → this.play()), // then capture each phase with debugSeek() so the shot lands on the // exact beat moment (no wall-clock racing; the scene keeps stepping). // Timeline (two-digit level): old 180–950, corrupt 950, scramble 1250, // locks 1580/1720, lock-in 1810, peel 2630, end 2960. const seek = async (ms) => { await p.ev(`(() => { const g = window.game.scene.getScene('GameScene'); if (g.levelMarquee.active) g.levelMarquee.debugSeek(${ms}); return g.levelMarquee.active; })()`); await sleep(150); // the latch is synchronous — let the paint land }; const release = async () => { await p.ev(`(() => { const g = window.game.scene.getScene('GameScene'); if (g.levelMarquee.active) g.levelMarquee.debugRelease(); return true; })()`); }; await p.ev(`(() => { const g = window.game.scene.getScene('GameScene'); g.levelMarquee.play({ from: 7, to: 9, levelsGained: 2 }); return g.levelMarquee.active; })()`); await seek(700); // old level booted in, the decode line live await p.shot(`${OUT}/marquee-old.png`); await seek(1020); // the corruption burst + shatter (shards mid-fall) await p.shot(`${OUT}/marquee-shatter.png`); await seek(1560); // the digit slots cycling, the first lock landing await p.shot(`${OUT}/marquee-scramble.png`); await seek(1820); // the lock-in: flash + shockwave + sparks await p.shot(`${OUT}/marquee-lock.png`); await seek(2250); // the hold: SYNC COMPLETE ▸ +2 LEVELS + the armor line await p.shot(`${OUT}/marquee-sync.png`); await seek(2720); // the neon shutter peeling the frame away await p.shot(`${OUT}/marquee-peel.png`); await release(); await p.waitTrue('!window.game.scene.getScene("GameScene").levelMarquee.active', 8000); // The cap branch: FIRMWARE MAXED ▸ LEVEL 80. await p.ev(`(() => { const g = window.game.scene.getScene('GameScene'); g.levelMarquee.play({ from: 78, to: 80, levelsGained: 2 }); return g.levelMarquee.active; })()`); await seek(2200); // the lock-in + the MAXED line up await p.shot(`${OUT}/marquee-max.png`); await release(); // Let the beat finish (no beat left running when we close). await p.waitTrue('!window.game.scene.getScene("GameScene").levelMarquee.active', 8000); } finally { await p.close(); } console.log('marquee screenshots: done'); } main().catch((err) => { console.error('marquee-shot failed:', err.message); process.exit(1); });