Files
healer-man/scripts/playtest-party-equipment.cjs
T
2026-10-06 22:25:34 -04:00

245 lines
14 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 {
DEFAULT_TEST_DUNGEON_TITLE,
DEFAULT_TEST_DUNGEON_CATEGORY,
openSelectedDungeonRoles,
} = require('./playtest-defaults.cjs');
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const output = path.resolve('playtest-artifacts/party-equipment', process.argv.includes('--basic-only') ? 'basic' : '.');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-equipment-'));
fs.mkdirSync(output, { recursive: true });
async function capture(page, name) {
await page.screenshot({ path: path.join(output, `${name}.png`) });
}
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir, staticDir: path.resolve('dist'), contentDir: path.resolve('content'), enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(0, '127.0.0.1', resolve));
console.log({ cwd: process.cwd(), staticDir: runtime.staticDir, indexExists: fs.existsSync(path.join(runtime.staticDir, 'index.html')), port: runtime.server.address().port });
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const results = {};
try {
for (const category of process.argv.includes('--rom-only') ? ['RoM'] : ['WoW', 'RoM']) {
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } });
const page = await context.newPage();
// Test-only observation of the existing canvas; no alternate character renderer.
await page.route(/\/assets\/SkeletonUtils-.*\.js$/, async (route) => {
const response = await route.fetch();
const source = await response.text();
const instrumented = source.replace(/onCreated:([\w$]+)=>\{(?=\1\.events\.connect)/,
'onCreated:$1=>{(window.__equipmentQaScenes??=[]).push($1);');
assert.notEqual(instrumented, source, 'The built canvas creation hook must be observable.');
await route.fulfill({ response, body: instrumented });
});
page.setDefaultTimeout(60000);
const errors = [];
let inspectedParty = [];
let bowFacing = null;
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error' && !message.text().startsWith('Failed to load resource:')) errors.push(message.text());
});
page.on('response', (response) => {
if (response.status() >= 400 && !response.url().endsWith('/favicon.ico')) errors.push(`${response.status()}: ${response.url()}`);
});
try {
await page.goto(`http://127.0.0.1:${runtime.server.address().port}`, { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: /Play offline/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.creator-category-tabs button').filter({ hasText: category }).click();
await page.locator('.class-option').filter({ hasText: 'Priest' }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name', { exact: true }).fill(category === 'WoW' ? 'Wowgear' : 'Romgear');
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.character-select-screen').waitFor();
await page.waitForTimeout(4000);
await capture(page, `${category}-roster-basic`);
await page.getByRole('button', { name: /Continue as/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
if (!process.argv.includes('--basic-only')) {
await page.getByRole('button', { name: /Downloads/ }).click();
const pack = page.locator('.content-pack-row').filter({ hasText: category === 'WoW'
? 'Extra Wrath Character Models & Skins' : 'RuneWaker Character Models & Skins' });
await pack.getByRole('button', { name: 'Download', exact: true }).click();
await pack.getByText('Installed — offline ready', { exact: true }).waitFor({ timeout: 180000 });
await page.getByRole('button', { name: 'Back', exact: true }).click();
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await page.locator('.character-select-screen').waitFor();
await page.waitForTimeout(5000);
await capture(page, `${category}-roster`);
await page.getByRole('button', { name: /Continue as/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
}
await page.getByRole('button', { name: /^Dungeons/ }).click();
await page.locator('.dungeon-category-tab').filter({ hasText: DEFAULT_TEST_DUNGEON_CATEGORY }).click();
await page.locator('.dungeon-select-row').filter({ hasText: DEFAULT_TEST_DUNGEON_TITLE }).first().click();
const download = page.getByRole('button', { name: 'Download and continue', exact: true });
if (await download.isVisible()) {
await download.click();
await page.locator('.dungeon-role-option').first().waitFor({ timeout: 180000 });
} else {
await openSelectedDungeonRoles(page);
}
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.scene-shell').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'hidden' });
await page.locator('.party-frames').waitFor();
await page.waitForTimeout(12000);
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden' });
await capture(page, `${category}-dungeon`);
if (category === 'RoM') {
inspectedParty = await page.evaluate(() => {
const canvas = document.querySelector('.scene-shell canvas');
const state = window.__equipmentQaScenes?.find((entry) => entry.gl.domElement === canvas);
if (!state) throw new Error('Could not inspect the live shared scene.');
window.equipmentQaScene = state;
const actors = state.scene.getObjectByName('party-population').children;
return actors.map((actor) => {
const weapons = [];
const sourceMeshes = [];
actor.traverse((node) => {
if (node.name.startsWith('equipped-')) weapons.push(node.name);
if (/^(blade01|hammer01|shield01|staff00|bow03|dagger01)_/.test(node.name)) sourceMeshes.push(node.name);
});
if (!sourceMeshes.length) throw new Error(`${actor.name} must carry original RoM weapon meshes.`);
return { name: actor.name, position: actor.position.toArray(), weapons, sourceMeshes };
});
});
console.log(JSON.stringify({ inspection: inspectedParty }, null, 2));
await page.evaluate(() => {
const scene = window.equipmentQaScene.scene;
const actors = scene.getObjectByName('party-population').children;
const scout = actors.find((actor) => {
let found = false;
actor.traverse((node) => { if (node.name.startsWith('bow03_')) found = true; });
return found;
});
if (!scout) throw new Error('Caelan must carry the original bow03 RoM mesh.');
const state = scout.__r3f.root.getState();
window.equipmentQaScene = state;
let bow;
scout.traverse((node) => { if (node.name.startsWith('bow03_')) bow = node; });
let prop = bow;
while (prop && !prop.name.startsWith('equipped-')) prop = prop.parent;
window.equipmentQaBow = { bow, prop };
const vector = scout.position.clone();
const eye = state.camera.position.clone();
window.equipmentQaOriginalFov = state.camera.fov;
const view = {
ref: { current: () => {
const center = scout.getWorldPosition(vector.clone());
center.y += 0.95;
// Keep the normal camera's collision-safe location in the corridor.
state.camera.position.copy(eye);
state.camera.fov = 40;
state.camera.updateProjectionMatrix();
state.camera.lookAt(center);
} }, priority: 0, store: scout.__r3f.root,
};
state.internal.subscribers.push(view);
state.internal.subscribers.sort((left, right) => left.priority - right.priority);
window.equipmentQaCameraView = view;
});
await page.waitForTimeout(1200);
bowFacing = await page.evaluate(() => {
window.equipmentQaBowFacing = () => {
const { bow, prop } = window.equipmentQaBow;
bow.updateWorldMatrix(true, true);
const center = bow.position.clone().set(0, 0, 0);
const tips = center.clone();
const upper = center.clone();
const lower = center.clone();
let upperCount = 0;
let lowerCount = 0;
let centerCount = 0;
let tipCount = 0;
const positions = bow.geometry.attributes.position;
for (let index = 0; index < positions.count; index++) {
const y = Math.abs(positions.getY(index));
const point = bow.localToWorld(bow.getVertexPosition(index, center.clone().set(0, 0, 0)));
if (y < 0.2) { center.add(point); centerCount++; }
if (y > 0.8) { tips.add(point); tipCount++; }
if (positions.getY(index) > 0.8) { upper.add(point); upperCount++; }
if (positions.getY(index) < -0.8) { lower.add(point); lowerCount++; }
}
center.divideScalar(centerCount);
tips.divideScalar(tipCount);
upper.divideScalar(upperCount);
lower.divideScalar(lowerCount);
const actor = window.equipmentQaScene.scene.getObjectByName('party-population').children.find((node) => {
let found = false;
node.traverse((child) => { if (child === bow) found = true; });
return found;
});
const chest = actor.localToWorld(center.clone().set(0, 1.1, 0));
const facing = tips.sub(center).normalize().dot(chest.sub(prop.getWorldPosition(center.clone())).normalize());
return { stringSideTowardTorso: facing, longAxisUp: Math.abs(upper.sub(lower).normalize().y), grip: prop.getWorldPosition(center.clone()).toArray() };
};
return window.equipmentQaBowFacing();
});
console.log(JSON.stringify({ bowFacing }));
assert.ok(bowFacing.stringSideTowardTorso > 0.1, 'The original bow string side must face Caelan’s torso.');
await capture(page, process.argv.includes('--basic-only') ? 'RoM-Caelan-basic' : 'RoM-Caelan-native');
await page.evaluate(() => {
const subscribers = window.equipmentQaScene.internal.subscribers;
const index = subscribers.indexOf(window.equipmentQaCameraView);
if (index >= 0) subscribers.splice(index, 1);
window.equipmentQaScene.camera.fov = window.equipmentQaOriginalFov;
window.equipmentQaScene.camera.updateProjectionMatrix();
});
}
// Rotate the camera with the normal held-right-mouse gesture to show the party fronts.
const bounds = await page.locator('.scene-shell canvas').boundingBox();
await page.mouse.move(bounds.x + bounds.width * 0.5, bounds.y + bounds.height * 0.55);
await page.mouse.down({ button: 'right' });
await page.mouse.move(bounds.x + bounds.width * 0.5 + 440, bounds.y + bounds.height * 0.55, { steps: 40 });
await page.mouse.up({ button: 'right' });
await page.waitForTimeout(1200);
await capture(page, `${category}-party-front`);
if (category === 'RoM') {
await page.locator('.scene-shell').focus();
await page.keyboard.down('s');
await page.waitForTimeout(2000);
await page.keyboard.up('s');
await page.mouse.wheel(700, 700);
await page.waitForTimeout(2500);
await capture(page, `${category}-party-open`);
}
assert.deepEqual(errors, []);
results[category] = {
dungeon: DEFAULT_TEST_DUNGEON_TITLE,
installedNativeCharacterPack: !process.argv.includes('--basic-only'),
screenshots: [`${category}-roster-basic.png`,
...(!process.argv.includes('--basic-only') ? [`${category}-roster.png`] : []),
`${category}-dungeon.png`, `${category}-party-front.png`,
...(category === 'RoM' ? [`${category}-party-open.png`, process.argv.includes('--basic-only') ? 'RoM-Caelan-basic.png' : 'RoM-Caelan-native.png'] : [])],
inspectedParty,
bowFacing,
errors,
};
} catch (error) {
await capture(page, `${category}-failure`);
console.error((await page.locator('body').innerText()).slice(-2500));
console.error(errors);
throw error;
} finally {
await context.close();
}
}
fs.writeFileSync(path.join(output, 'results.json'), `${JSON.stringify(results, null, 2)}\n`);
console.log(JSON.stringify(results, null, 2));
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
assert.equal(path.dirname(path.resolve(dataDir)), path.resolve(os.tmpdir()));
assert.ok(path.basename(dataDir).startsWith('healer-man-equipment-'));
fs.rmSync(dataDir, { recursive: true, force: true });
}
})().catch((error) => { console.error(error); process.exitCode = 1; });