mirror of
https://github.com/Dvorinka/SEEN.git
synced 2026-06-03 20:13:02 +00:00
357 lines
9.7 KiB
JavaScript
357 lines
9.7 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { chromium } from "playwright";
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
url: null,
|
|
iterations: 3,
|
|
pauseMs: 250,
|
|
headless: true,
|
|
screenshotDir: "output/web-game",
|
|
actionsFile: null,
|
|
actionsJson: null,
|
|
click: null,
|
|
clickSelector: null,
|
|
};
|
|
for (let i = 2; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
const next = argv[i + 1];
|
|
if (arg === "--url" && next) {
|
|
args.url = next;
|
|
i++;
|
|
} else if (arg === "--iterations" && next) {
|
|
args.iterations = parseInt(next, 10);
|
|
i++;
|
|
} else if (arg === "--pause-ms" && next) {
|
|
args.pauseMs = parseInt(next, 10);
|
|
i++;
|
|
} else if (arg === "--headless" && next) {
|
|
args.headless = next !== "0" && next !== "false";
|
|
i++;
|
|
} else if (arg === "--screenshot-dir" && next) {
|
|
args.screenshotDir = next;
|
|
i++;
|
|
} else if (arg === "--actions-file" && next) {
|
|
args.actionsFile = next;
|
|
i++;
|
|
} else if (arg === "--actions-json" && next) {
|
|
args.actionsJson = next;
|
|
i++;
|
|
} else if (arg === "--click" && next) {
|
|
const parts = next.split(",").map((v) => parseFloat(v.trim()));
|
|
if (parts.length === 2 && parts.every((v) => Number.isFinite(v))) {
|
|
args.click = { x: parts[0], y: parts[1] };
|
|
}
|
|
i++;
|
|
} else if (arg === "--click-selector" && next) {
|
|
args.clickSelector = next;
|
|
i++;
|
|
}
|
|
}
|
|
if (!args.url) {
|
|
throw new Error("--url is required");
|
|
}
|
|
return args;
|
|
}
|
|
|
|
const buttonNameToKey = {
|
|
up: "ArrowUp",
|
|
down: "ArrowDown",
|
|
left: "ArrowLeft",
|
|
right: "ArrowRight",
|
|
enter: "Enter",
|
|
space: "Space",
|
|
a: "KeyA",
|
|
b: "KeyB",
|
|
};
|
|
|
|
async function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function ensureDir(p) {
|
|
fs.mkdirSync(p, { recursive: true });
|
|
}
|
|
|
|
function makeVirtualTimeShim() {
|
|
return `(() => {
|
|
const pending = new Set();
|
|
const origSetTimeout = window.setTimeout.bind(window);
|
|
const origSetInterval = window.setInterval.bind(window);
|
|
const origRequestAnimationFrame = window.requestAnimationFrame.bind(window);
|
|
|
|
window.__vt_pending = pending;
|
|
|
|
window.setTimeout = (fn, t, ...rest) => {
|
|
const task = {};
|
|
pending.add(task);
|
|
return origSetTimeout(() => {
|
|
pending.delete(task);
|
|
fn(...rest);
|
|
}, t);
|
|
};
|
|
|
|
window.setInterval = (fn, t, ...rest) => {
|
|
const task = {};
|
|
pending.add(task);
|
|
return origSetInterval(() => {
|
|
fn(...rest);
|
|
}, t);
|
|
};
|
|
|
|
window.requestAnimationFrame = (fn) => {
|
|
const task = {};
|
|
pending.add(task);
|
|
return origRequestAnimationFrame((ts) => {
|
|
pending.delete(task);
|
|
fn(ts);
|
|
});
|
|
};
|
|
|
|
window.advanceTime = (ms) => {
|
|
return new Promise((resolve) => {
|
|
const start = performance.now();
|
|
function step(now) {
|
|
if (now - start >= ms) return resolve();
|
|
origRequestAnimationFrame(step);
|
|
}
|
|
origRequestAnimationFrame(step);
|
|
});
|
|
};
|
|
|
|
window.__drainVirtualTimePending = () => pending.size;
|
|
})();`;
|
|
}
|
|
|
|
async function getCanvasHandle(page) {
|
|
const handle = await page.evaluateHandle(() => {
|
|
let best = null;
|
|
let bestArea = 0;
|
|
for (const canvas of document.querySelectorAll("canvas")) {
|
|
const area = (canvas.width || canvas.clientWidth || 0) * (canvas.height || canvas.clientHeight || 0);
|
|
if (area > bestArea) {
|
|
bestArea = area;
|
|
best = canvas;
|
|
}
|
|
}
|
|
return best;
|
|
});
|
|
return handle.asElement();
|
|
}
|
|
|
|
async function captureCanvasPngBase64(canvas) {
|
|
return canvas.evaluate((c) => {
|
|
if (!c || typeof c.toDataURL !== "function") return "";
|
|
const data = c.toDataURL("image/png");
|
|
const idx = data.indexOf(",");
|
|
return idx === -1 ? "" : data.slice(idx + 1);
|
|
});
|
|
}
|
|
|
|
async function isCanvasTransparent(canvas) {
|
|
if (!canvas) return true;
|
|
return canvas.evaluate((c) => {
|
|
try {
|
|
const w = c.width || c.clientWidth || 0;
|
|
const h = c.height || c.clientHeight || 0;
|
|
if (!w || !h) return true;
|
|
const size = Math.max(1, Math.min(16, w, h));
|
|
const probe = document.createElement("canvas");
|
|
probe.width = size;
|
|
probe.height = size;
|
|
const ctx = probe.getContext("2d");
|
|
if (!ctx) return true;
|
|
ctx.drawImage(c, 0, 0, size, size);
|
|
const data = ctx.getImageData(0, 0, size, size).data;
|
|
for (let i = 3; i < data.length; i += 4) {
|
|
if (data[i] !== 0) return false;
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function captureScreenshot(page, canvas, outPath) {
|
|
let buffer = null;
|
|
let base64 = canvas ? await captureCanvasPngBase64(canvas) : "";
|
|
if (base64) {
|
|
buffer = Buffer.from(base64, "base64");
|
|
const transparent = canvas ? await isCanvasTransparent(canvas) : false;
|
|
if (transparent) buffer = null;
|
|
}
|
|
if (!buffer && canvas) {
|
|
try {
|
|
buffer = await canvas.screenshot({ type: "png" });
|
|
} catch {
|
|
buffer = null;
|
|
}
|
|
}
|
|
if (!buffer) {
|
|
const bbox = canvas ? await canvas.boundingBox() : null;
|
|
if (bbox) {
|
|
buffer = await page.screenshot({
|
|
type: "png",
|
|
omitBackground: false,
|
|
clip: bbox,
|
|
});
|
|
} else {
|
|
buffer = await page.screenshot({ type: "png", omitBackground: false });
|
|
}
|
|
}
|
|
fs.writeFileSync(outPath, buffer);
|
|
}
|
|
|
|
class ConsoleErrorTracker {
|
|
constructor() {
|
|
this._seen = new Set();
|
|
this._errors = [];
|
|
}
|
|
|
|
ingest(err) {
|
|
const key = JSON.stringify(err);
|
|
if (this._seen.has(key)) return;
|
|
this._seen.add(key);
|
|
this._errors.push(err);
|
|
}
|
|
|
|
drain() {
|
|
const next = [...this._errors];
|
|
this._errors = [];
|
|
return next;
|
|
}
|
|
}
|
|
|
|
async function doChoreography(page, canvas, steps) {
|
|
for (const step of steps) {
|
|
const buttons = new Set(step.buttons || []);
|
|
for (const button of buttons) {
|
|
if (button === "left_mouse_button" || button === "right_mouse_button") {
|
|
const bbox = canvas ? await canvas.boundingBox() : null;
|
|
if (!bbox) continue;
|
|
const x = typeof step.mouse_x === "number" ? step.mouse_x : bbox.width / 2;
|
|
const y = typeof step.mouse_y === "number" ? step.mouse_y : bbox.height / 2;
|
|
await page.mouse.move(bbox.x + x, bbox.y + y);
|
|
await page.mouse.down({ button: button === "left_mouse_button" ? "left" : "right" });
|
|
} else if (buttonNameToKey[button]) {
|
|
await page.keyboard.down(buttonNameToKey[button]);
|
|
}
|
|
}
|
|
|
|
const frames = step.frames || 1;
|
|
for (let i = 0; i < frames; i++) {
|
|
await page.evaluate(async () => {
|
|
if (typeof window.advanceTime === "function") {
|
|
await window.advanceTime(1000 / 60);
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const button of buttons) {
|
|
if (button === "left_mouse_button" || button === "right_mouse_button") {
|
|
await page.mouse.up({ button: button === "left_mouse_button" ? "left" : "right" });
|
|
} else if (buttonNameToKey[button]) {
|
|
await page.keyboard.up(buttonNameToKey[button]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv);
|
|
ensureDir(args.screenshotDir);
|
|
|
|
const browser = await chromium.launch({
|
|
headless: args.headless,
|
|
args: ["--use-gl=angle", "--use-angle=swiftshader"],
|
|
});
|
|
const page = await browser.newPage();
|
|
const consoleErrors = new ConsoleErrorTracker();
|
|
|
|
page.on("console", (msg) => {
|
|
if (msg.type() !== "error") return;
|
|
consoleErrors.ingest({ type: "console.error", text: msg.text() });
|
|
});
|
|
page.on("pageerror", (err) => {
|
|
consoleErrors.ingest({ type: "pageerror", text: String(err) });
|
|
});
|
|
|
|
await page.addInitScript({ content: makeVirtualTimeShim() });
|
|
await page.goto(args.url, { waitUntil: "domcontentloaded" });
|
|
await page.waitForTimeout(500);
|
|
await page.evaluate(() => {
|
|
window.dispatchEvent(new Event("resize"));
|
|
});
|
|
|
|
let canvas = await getCanvasHandle(page);
|
|
|
|
if (args.clickSelector) {
|
|
try {
|
|
await page.click(args.clickSelector, { timeout: 5000 });
|
|
await page.waitForTimeout(250);
|
|
} catch (err) {
|
|
console.warn("Failed to click selector", args.clickSelector, err);
|
|
}
|
|
}
|
|
let steps = null;
|
|
if (args.actionsFile) {
|
|
const raw = fs.readFileSync(args.actionsFile, "utf-8");
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) steps = parsed;
|
|
if (parsed && Array.isArray(parsed.steps)) steps = parsed.steps;
|
|
} else if (args.actionsJson) {
|
|
const parsed = JSON.parse(args.actionsJson);
|
|
if (Array.isArray(parsed)) steps = parsed;
|
|
if (parsed && Array.isArray(parsed.steps)) steps = parsed.steps;
|
|
} else if (args.click) {
|
|
steps = [
|
|
{
|
|
buttons: ["left_mouse_button"],
|
|
frames: 2,
|
|
mouse_x: args.click.x,
|
|
mouse_y: args.click.y,
|
|
},
|
|
];
|
|
}
|
|
if (!steps) {
|
|
throw new Error("Actions are required. Use --actions-file, --actions-json, or --click.");
|
|
}
|
|
|
|
for (let i = 0; i < args.iterations; i++) {
|
|
if (!canvas) canvas = await getCanvasHandle(page);
|
|
await doChoreography(page, canvas, steps);
|
|
await sleep(args.pauseMs);
|
|
|
|
const shotPath = path.join(args.screenshotDir, `shot-${i}.png`);
|
|
await captureScreenshot(page, canvas, shotPath);
|
|
|
|
const text = await page.evaluate(() => {
|
|
if (typeof window.render_game_to_text === "function") {
|
|
return window.render_game_to_text();
|
|
}
|
|
return null;
|
|
});
|
|
if (text) {
|
|
fs.writeFileSync(path.join(args.screenshotDir, `state-${i}.json`), text);
|
|
}
|
|
|
|
const freshErrors = consoleErrors.drain();
|
|
if (freshErrors.length) {
|
|
fs.writeFileSync(
|
|
path.join(args.screenshotDir, `errors-${i}.json`),
|
|
JSON.stringify(freshErrors, null, 2)
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
await browser.close();
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|