Files
healer-man/scripts/playtest-rom-visual-routes.cjs
T
2026-10-06 22:25:34 -04:00

250 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const { openSelectedDungeonRoles } = require('./playtest-defaults.cjs');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const assert = require('node:assert/strict');
const { createHash } = require('node:crypto');
// Source-specific rendering/footing QA. Entry and movement use ordinary UI and
// keyboard input. Runtime stores are read-only; this does not certify combat.
const option = name => process.argv.find(value => value.startsWith(`--${name}=`))?.split('=').slice(1).join('=');
const ids = (option('dungeon') ?? 'sand-vortex,the-origin').split(',');
const output = path.resolve(option('output') ?? `artifacts/playtest/rom-visual-routes/${Date.now()}`);
const routeFile = option('routes');
const candidateFile = option('candidate-assets');
const candidates = candidateFile ? JSON.parse(fs.readFileSync(candidateFile, 'utf8')) : {};
const entryOnly = process.argv.includes('--entry-only');
const captureEvery = Math.max(1, Number(option('capture-every') ?? 1));
assert.ok(Number.isInteger(captureEvery), 'Capture interval must be a positive integer.');
const routePlans = routeFile ? JSON.parse(fs.readFileSync(routeFile, 'utf8')) : {};
const recipes = ids.map(id => JSON.parse(fs.readFileSync(`scripts/runewaker-pipeline/recipes/${id}.json`, 'utf8')));
const staticDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rom-visual-dist-'));
fs.cpSync(path.resolve(option('dist') ?? 'dist'), staticDir, { recursive: true });
const modules = fs.readdirSync(path.join(staticDir, 'assets')).filter(file => /^(?:index|GameRuntime|combatStore|partyStore|PartyFrames)-.*\.js$/.test(file));
assert.ok(modules.length >= 3, 'Build the production game before route QA.');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rom-visual-'));
fs.mkdirSync(output, { recursive: true });
async function snapshot(page) {
return page.evaluate(async () => {
const { game, combat } = await window.romVisualStores();
const state = game.getState();
const player = combat.getState();
return { at: Date.now(), dungeon: state.activeDungeonId, status: state.assetStatus,
position: state.playerPosition, navigation: state.playerNavigationPosition,
grounded: state.playerGrounded, yaw: state.cameraYaw, explored: state.distanceTravelled,
health: player.health, maxHealth: player.maxHealth, settings: player.settings,
pointerLocked: document.pointerLockElement !== null };
});
}
async function pause(page) {
await page.locator('.scene-shell').focus();
await page.keyboard.press('Escape');
await page.getByRole('dialog', { name: 'Paused' }).waitFor();
}
async function quality(page, shadow) {
await pause(page);
await page.getByRole('button', { name: 'Options', exact: true }).click();
await page.getByRole('dialog', { name: 'Options' }).waitFor();
const setting = name => page.locator('.options-panel .option-row').filter({ has: page.getByText(name, { exact: true }) }).getByRole('button').first();
for (const [name, target, count] of [['Render resolution', '150%', 4], ['Texture filtering', '16×', 3], ['Shadows', shadow, 3], ['Ambient fill', 'Authored', 3]]) {
const button = setting(name);
for (let index = 0; index < count && (await button.innerText()).trim() !== target; index++) await button.click();
assert.equal((await button.innerText()).trim(), target, `Graphics option ${name}`);
}
await page.getByRole('button', { name: 'Done', exact: true }).click();
await page.getByRole('button', { name: 'Resume game' }).click();
await page.locator('.scene-shell').focus();
await page.waitForTimeout(400);
}
function keysTo(from, target, yaw) {
const dx = target[0] - from[0], dz = target[2] - from[2];
const length = Math.hypot(dx, dz) || 1;
const x = -dx / length * Math.cos(yaw) + dz / length * Math.sin(yaw);
const forward = dx / length * Math.sin(yaw) + dz / length * Math.cos(yaw);
return [...(forward > 0.32 ? ['w'] : forward < -0.32 ? ['s'] : []), ...(x > 0.32 ? ['d'] : x < -0.32 ? ['a'] : [])];
}
async function moveTo(page, target, samples) {
let stalled = 0;
const initial = await snapshot(page);
for (let tick = 0; tick < 40; tick++) {
const before = await snapshot(page);
if (before.health <= 0) return { target, outcome: 'player-died', initial, final: before };
if (Math.hypot(target[0] - before.position[0], target[2] - before.position[2]) < 0.75) return { target, outcome: 'reached', initial, final: before };
const keys = keysTo(before.position, target, before.yaw);
for (const key of keys) await page.keyboard.down(key);
await page.waitForTimeout(250);
for (const key of keys) await page.keyboard.up(key);
const after = await snapshot(page);
samples.push(after);
const moved = Math.hypot(after.position[0] - before.position[0], after.position[2] - before.position[2]);
stalled = moved < 0.05 ? stalled + 1 : 0;
if (stalled >= 6) return { target, outcome: 'blocked', initial, final: after };
}
return { target, outcome: 'time-budget', initial, final: await snapshot(page) };
}
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir, staticDir, enableShelvedModes: false });
await new Promise((resolve, reject) => { runtime.server.once('error', reject); runtime.server.listen(0, '127.0.0.1', resolve); });
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
// The game has no favicon.ico; suppress this browser-only request in QA.
await page.route('**/favicon.ico', route => route.fulfill({ status: 204 }));
// Freeze the release manifest as well as the build. Content objects are
// immutable SHA-addressed files, so another local publish cannot change this
// browser's pack selection halfway through a long all-dungeon review.
const manifestBytes = fs.readFileSync(path.resolve(option('manifest') ?? 'content/manifest.json'));
const frozenManifest = JSON.parse(manifestBytes);
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
if (!candidateFile) await page.route('**/content/manifest.json', route => route.fulfill({ body: manifestBytes, contentType: 'application/json' }));
fs.writeFileSync(path.join(output, 'build-evidence.json'), JSON.stringify({
dist: path.resolve(option('dist') ?? 'dist'),
indexSha256: sha256(fs.readFileSync(path.join(staticDir, 'index.html'))),
manifestSha256: sha256(manifestBytes), manifestVersion: frozenManifest.version,
modules: modules.map(file => ({ file, sha256: sha256(fs.readFileSync(path.join(staticDir, 'assets', file))) })),
}, null, 2) + '\n');
// Install staged geometry through the ordinary, SHA-verified content flow.
// Only this browser receives the candidate manifest and object responses.
const candidateProof = [];
if (candidateFile) {
const { createHash } = require('node:crypto');
const manifest = JSON.parse(fs.readFileSync(path.resolve('content/manifest.json'), 'utf8'));
for (const [logicalPath, file] of Object.entries(candidates)) {
const previous = manifest.files[logicalPath];
assert.ok(previous, `Unknown candidate logical asset ${logicalPath}`);
const absolute = path.resolve(file);
const bytes = fs.readFileSync(absolute);
const sha256 = createHash('sha256').update(bytes).digest('hex');
const url = `/content/objects/${sha256}${path.extname(logicalPath)}`;
manifest.files[logicalPath] = { packId: previous.packId, url, sha256, bytes: bytes.length };
await page.route(`**${url}`, route => route.fulfill({ path: absolute, contentType: 'application/octet-stream' }));
candidateProof.push({ logicalPath, file: absolute, sha256, bytes: bytes.length, previousSha256: previous.sha256 });
}
for (const pack of manifest.packs) {
const files = Object.values(manifest.files).filter(file => file.packId === pack.id);
pack.bytes = files.reduce((sum, file) => sum + file.bytes, 0);
pack.downloadBytes = files.reduce((sum, file) => sum + (file.download?.bytes ?? file.bytes), 0);
pack.fileCount = files.length;
}
manifest.version = `candidate-${createHash('sha256').update(JSON.stringify(manifest.files)).digest('hex').slice(0, 16)}`;
await page.route('**/content/manifest.json', route => route.fulfill({ json: manifest }));
fs.writeFileSync(path.join(output, 'candidate-assets-proof.json'), JSON.stringify(candidateProof, null, 2) + '\n');
}
page.setDefaultTimeout(180000);
const errors = [], results = [];
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
await page.addInitScript(({ urls }) => {
window.romVisualStores = async () => {
const exports = (await Promise.all(urls.map(url => import(url)))).flatMap(module => Object.values(module));
const stores = exports.filter(value => value && typeof value.getState === 'function');
return { game: stores.find(store => typeof store.getState()?.relocatePlayer === 'function'),
combat: stores.find(store => typeof store.getState()?.castAbility === 'function') };
};
}, { urls: modules.map(file => `/assets/${file}`) });
try {
await page.goto(`http://127.0.0.1:${runtime.server.address().port}`, { waitUntil: 'domcontentloaded' });
console.log('Booted isolated production snapshot.');
await page.getByRole('button', { name: /Play offline/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('RoMVisualRoute');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue|Enter world/ }).first().evaluate(button => button.click());
await page.locator('.main-menu-screen').waitFor();
console.log('Created offline visual QA character.');
for (const recipe of recipes) {
const startError = errors.length;
await page.getByRole('button', { name: /^Dungeons/ }).click();
await page.locator('.dungeon-category-tab').filter({ hasText: 'RoM' }).click();
await page.locator('.dungeon-select-row').filter({ has: page.getByText(recipe.title, { exact: true }) }).click();
await openSelectedDungeonRoles(page);
await page.locator('.dungeon-role-option--healer').click();
console.log(`${recipe.slug}: entered role; waiting for scene.`);
await page.locator('.scene-shell').waitFor();
await page.evaluate(async () => {
window.romVisualGameStore = (await window.romVisualStores()).game;
window.romVisualReadySince = 0;
window.romVisualLoadingObserved = window.romVisualGameStore.getState().assetStatus === 'loading';
});
await page.waitForFunction(({ id }) => {
const state = window.romVisualGameStore.getState();
if (state.activeDungeonId !== id) return false;
if (state.assetStatus === 'loading') window.romVisualLoadingObserved = true;
if (state.assetStatus === 'error') return true;
if (!window.romVisualLoadingObserved || state.assetStatus !== 'ready' || state.dungeonIntroActive) {
window.romVisualReadySince = 0;
return false;
}
window.romVisualReadySince ||= Date.now();
return Date.now() - window.romVisualReadySince >= 5000;
}, { id: recipe.slug }, { timeout: 180000 });
await page.locator('.loading-screen').waitFor({ state: 'hidden' });
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden' });
await page.waitForTimeout(1200);
const entry = await snapshot(page);
console.log(`${recipe.slug}: scene ${entry.status} at ${entry.position.join(',')}.`);
assert.equal(entry.dungeon, recipe.slug);
assert.equal(entry.status, 'ready', 'The actual dungeon assets must finish loading.');
const samples = [entry], legs = [];
await quality(page, 'Standard');
const standardEntry = await snapshot(page);
let highEntry = null;
await page.screenshot({ path: path.join(output, `${recipe.slug}-entry-standard.png`) });
if (entryOnly) {
await quality(page, 'High');
highEntry = await snapshot(page);
await page.screenshot({ path: path.join(output, `${recipe.slug}-entry-high.png`) });
}
const planned = entryOnly ? [] : routePlans[recipe.slug]?.points ?? routePlans[recipe.slug] ?? [
[entry.position[0] + Math.sin(entry.yaw) * 10, entry.position[1], entry.position[2] + Math.cos(entry.yaw) * 10],
];
for (const [index, point] of planned.entries()) {
const leg = await moveTo(page, point, samples);
legs.push(leg);
const capture = (index + 1) % captureEvery === 0 || index === planned.length - 1 || leg.outcome !== 'reached';
if (capture) {
await page.screenshot({ path: path.join(output, `${recipe.slug}-leg-${index}-standard.png`) });
await quality(page, 'High');
leg.high = await snapshot(page);
await page.screenshot({ path: path.join(output, `${recipe.slug}-leg-${index}-high.png`) });
}
if (leg.outcome !== 'reached') break;
if (capture) await quality(page, 'Standard');
}
const final = await snapshot(page);
results.push({ id: recipe.slug, title: recipe.title, entry, standardEntry, highEntry, legs, samples, final,
errors: errors.slice(startError), scope: entryOnly
? 'Ordinary entry and Standard/High graphics UI, read-only production telemetry; no traversal or combat certification.'
: 'Ordinary UI/keyboard movement, read-only production telemetry; no full-clear or combat certification.' });
fs.writeFileSync(path.join(output, 'results.json'), JSON.stringify({ productionModules: modules, results }, null, 2) + '\n');
console.log(`${recipe.slug}: ${legs.map(leg => leg.outcome).join(', ')}; grounded=${final.grounded}; explored=${final.explored}; errors=${errors.length - startError}`);
await pause(page);
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
await page.locator('.main-menu-screen').waitFor();
}
assert.deepEqual(errors, []);
} catch (error) {
await page.screenshot({ path: path.join(output, 'failure.png') }).catch(() => {});
fs.writeFileSync(path.join(output, 'failure.json'), JSON.stringify({ error: error.message, errors, results }, null, 2) + '\n');
console.error((await page.locator('body').innerText()).slice(-1800));
throw error;
} finally {
await browser.close();
await new Promise(resolve => runtime.server.close(resolve));
const resolved = path.resolve(dataDir);
assert.ok(resolved.startsWith(path.resolve(os.tmpdir()) + path.sep));
fs.rmSync(resolved, { recursive: true, force: true });
const resolvedStatic = path.resolve(staticDir);
assert.ok(resolvedStatic.startsWith(path.resolve(os.tmpdir()) + path.sep));
fs.rmSync(resolvedStatic, { recursive: true, force: true });
}
})().catch(error => { console.error(error); process.exitCode = 1; });