Files
healer-man/scripts/playtest-dungeon-simulation.cjs
T
2026-10-04 22:51:21 -04:00

194 lines
11 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_ID } = 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 crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const directory = path.resolve('playtest-artifacts', 'dungeon-simulation');
fs.mkdirSync(directory, { recursive: true });
const databaseDirectory = path.join(directory, 'test-database');
fs.rmSync(databaseDirectory, { recursive: true, force: true });
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({
dataDir: databaseDirectory,
enableShelvedModes: false,
});
await new Promise((resolve) => runtime.server.listen(4189, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true });
const page = await context.newPage();
const cameraModuleUrls = fs.readdirSync('dist/assets')
.filter((file) => /^(?:index|GameRuntime)-.*\.js$/.test(file))
.map((file) => `/assets/${file}`);
await page.addInitScript((moduleUrls) => {
window.readSpectatorCameraState = async () => {
const values = (await Promise.all(moduleUrls.map((url) => import(url)))).flatMap(Object.values);
const game = values.find((value) => value?.getState && 'cameraYaw' in value.getState() && 'activeDungeonId' in value.getState());
if (!game) throw new Error('Game camera store export was not found.');
return { yaw: game.getState().cameraYaw, pointerLocked: document.pointerLockElement !== null };
};
}, cameraModuleUrls);
page.setDefaultTimeout(90000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const capture = (name) => page.screenshot({ path: path.join(directory, `${name}.png`), fullPage: true });
const captureCanvas = async (name) => {
const canvas = page.locator('canvas');
await canvas.evaluate((element) => {
const hidden = [];
let current = element;
while (current.parentElement) {
for (const sibling of current.parentElement.children) {
if (sibling === current || !(sibling instanceof HTMLElement)) continue;
hidden.push([sibling, sibling.style.visibility]);
sibling.style.visibility = 'hidden';
}
current = current.parentElement;
}
window.__restoreCanvasCapture = () => {
for (const [node, visibility] of hidden) node.style.visibility = visibility;
delete window.__restoreCanvasCapture;
};
});
try {
const image = await canvas.screenshot(name ? { path: path.join(directory, `${name}.png`) } : undefined);
return crypto.createHash('sha256').update(image).digest('hex');
} finally {
await page.evaluate(() => window.__restoreCanvasCapture?.());
}
};
try {
await page.goto('http://127.0.0.1:4189', { waitUntil: 'domcontentloaded' });
await page.getByLabel('Username').fill('Phenom');
await page.getByLabel('Password').fill('dungeon-route-admin');
await page.getByRole('button', { name: /Create account/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('Routewatcher');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue/ }).first().click();
await page.getByRole('button', { name: /GM Tools/ }).click();
await page.getByRole('heading', { name: 'Dungeon route simulations' }).waitFor();
await capture('01-gm-route-launch');
const routeSelects = page.locator('.dungeon-sim-admin__catalog select');
await routeSelects.nth(0).selectOption(DEFAULT_TEST_DUNGEON_ID);
await routeSelects.nth(1).selectOption('navigation-only');
await page.locator('.dungeon-sim-admin__catalog input').fill('browser-smoke-fixed-seed');
await page.getByRole('button', { name: 'Launch spectate mode' }).click();
await page.locator('canvas').waitFor();
await page.getByLabel('Dungeon simulation controls').waitFor();
await page.waitForTimeout(6000);
await capture('02-live-navigation-simulation');
await page.getByRole('button', { name: 'Pause' }).click();
const pausedTime = await page.locator('.dungeon-sim-hud__header > time').innerText();
await page.waitForTimeout(700);
assert.equal(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedTime, 'pause freezes simulation time');
await page.getByRole('button', { name: 'Step' }).click();
await page.getByRole('button', { name: 'Step' }).click();
await page.waitForTimeout(200);
assert.notEqual(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedTime, 'single-step advances simulation time');
const memberPicker = page.getByLabel('Spectate party member');
const memberButtons = memberPicker.locator('div > button');
assert.equal(await memberButtons.count(), 5, 'all five simulated party members are spectatable');
const chosenName = await memberButtons.nth(2).locator('strong').innerText();
await memberButtons.nth(2).click();
assert.equal(await memberButtons.nth(2).getAttribute('aria-pressed'), 'true');
await page.waitForTimeout(300);
await capture('03-follow-selected-member');
const beforeFollowArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
await page.keyboard.down('ArrowLeft');
await page.waitForTimeout(300);
await page.keyboard.up('ArrowLeft');
const afterFollowArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
assert.notEqual(afterFollowArrowLook.yaw, beforeFollowArrowLook.yaw, 'ArrowLeft orbits the follow camera');
await page.getByRole('button', { name: 'Free camera' }).click();
assert.equal(await page.getByRole('button', { name: 'Free camera' }).getAttribute('aria-pressed'), 'true');
const beforeFlight = await captureCanvas('03-canvas-before-flight');
await page.keyboard.down('w');
await page.waitForTimeout(700);
await page.keyboard.up('w');
await page.waitForTimeout(150);
const afterFlight = await captureCanvas();
assert.notEqual(afterFlight, beforeFlight, 'free-camera movement redraws the 3D dungeon without relying on nameplate overlays');
const beforeArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
await page.keyboard.down('ArrowRight');
await page.waitForTimeout(300);
await page.keyboard.up('ArrowRight');
const afterArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
assert.notEqual(afterArrowLook.yaw, beforeArrowLook.yaw, 'ArrowRight turns the free camera');
const canvasBounds = await page.locator('canvas').boundingBox();
assert(canvasBounds, 'simulation canvas is visible');
const lookStart = { x: canvasBounds.x + canvasBounds.width * 0.5, y: canvasBounds.y + canvasBounds.height * 0.55 };
const beforeLook = await page.evaluate(() => window.readSpectatorCameraState());
await page.mouse.move(lookStart.x, lookStart.y);
await page.mouse.down({ button: 'right' });
await page.mouse.move(lookStart.x + 220, lookStart.y + 100, { steps: 12 });
await page.waitForTimeout(250);
const duringLook = await page.evaluate(() => window.readSpectatorCameraState());
assert.notEqual(duringLook.yaw, beforeLook.yaw, 'right-drag rotates the free camera');
assert.equal(duringLook.pointerLocked, false, 'right-drag does not request Pointer Lock');
await page.mouse.up({ button: 'right' });
const releasedLook = await page.evaluate(() => window.readSpectatorCameraState());
await page.mouse.move(lookStart.x - 120, lookStart.y + 50, { steps: 8 });
await page.waitForTimeout(250);
assert.equal(
(await page.evaluate(() => window.readSpectatorCameraState())).yaw,
releasedLook.yaw,
'mouse movement after right-button release does not rotate the camera',
);
await page.locator('canvas').screenshot({ path: path.join(directory, '04-canvas-after-flight-look.png') });
await capture('04-paused-free-camera-moved');
await memberButtons.nth(4).click();
assert.equal(await page.getByRole('button', { name: 'Follow member' }).getAttribute('aria-pressed'), 'true');
assert.match(await memberPicker.locator('header strong').innerText(), /\S/);
assert.notEqual(await memberPicker.locator('header strong').innerText(), chosenName);
await page.waitForTimeout(300);
await capture('05-follow-another-member');
await page.getByRole('button', { name: '4×' }).click();
await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(1200);
await page.getByRole('button', { name: 'Stop & save' }).click();
await page.getByRole('button', { name: 'Return to GM Tools' }).waitFor();
await capture('06-finished-report');
await page.getByRole('button', { name: 'Return to GM Tools' }).click();
await page.getByRole('heading', { name: 'Dungeon route simulations' }).waitFor();
await page.locator('.dungeon-sim-admin__catalog select').nth(0).selectOption(DEFAULT_TEST_DUNGEON_ID);
await page.getByText(/1 runs for this route/).waitFor();
await capture('07-saved-replay');
await page.getByRole('button', { name: 'Watch' }).first().click();
await page.getByText('Recorded replay').waitFor();
await page.getByLabel('Replay position').waitFor();
await page.waitForTimeout(6000);
await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(800);
await capture('08-replay-controls');
await page.getByRole('button', { name: 'Pause' }).click();
const pausedReplayTime = await page.locator('.dungeon-sim-hud__header > time').innerText();
await page.waitForTimeout(500);
assert.equal(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedReplayTime, 'replay pause freezes playback time');
await page.getByRole('button', { name: '4×' }).click();
await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(600);
await capture('09-replay-4x');
assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`);
console.log('Dungeon simulation browser smoke test passed.');
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});