250 lines
15 KiB
JavaScript
250 lines
15 KiB
JavaScript
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; });
|