185 lines
8.0 KiB
JavaScript
185 lines
8.0 KiB
JavaScript
/**
|
||
* 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); });
|