Update Healer Man server
This commit is contained in:
1 parent
6114a2295f
commit
69809f7228
593 files changed
+903643
-40774
No files matched your search
@@ -0,0 +1,249 @@
|
||||
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; });
|
||||
Reference in new issue
Block a user