Update Healer Man server

This commit is contained in:
phenom committed 2026-10-06 22:25:34 -04:00
1 parent 6114a2295f
commit 69809f7228
593 files changed
+903643 -40774

No files matched your search

+249
View File
@@ -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; });