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; });