899 lines
46 KiB
JavaScript
899 lines
46 KiB
JavaScript
const { DEFAULT_TEST_DUNGEON_ID, DEFAULT_TEST_DUNGEON_TITLE } = 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');
|
|
|
|
// UI-driven campaign route QA. Only the isolated profile's level and RoM
|
|
// secondary class are prepared directly; dungeon entry, movement, targeting,
|
|
// combat actions, and ally selection all use the ordinary UI and keyboard.
|
|
const artifactRoot = path.resolve('playtest-artifacts/native-routes');
|
|
const stamp = new Date().toISOString().replace(/[-:.]/g, '').replace('T', '-').replace('Z', '');
|
|
const baseline = process.argv.includes('--baseline');
|
|
const outputDirectory = path.join(artifactRoot, `${baseline ? 'baseline' : 'final'}-${stamp}`);
|
|
const durationOption = process.argv.find((value) => value.startsWith('--route-seconds='));
|
|
const routeDurationMs = Math.max(15, Number(durationOption?.split('=')[1] ?? (baseline ? 25 : 90))) * 1_000;
|
|
const routeSelection = process.argv.find((value) => value.startsWith('--route='))?.split('=')[1] ?? null;
|
|
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-native-routes-'));
|
|
const moduleFiles = fs.readdirSync('dist/assets')
|
|
.filter((file) => /^(?:index|GameRuntime|combatStore|partyStore|PartyFrames)-.*\.js$/.test(file));
|
|
const moduleUrls = moduleFiles.map((file) => `/assets/${file}`);
|
|
const routes = [
|
|
{ id: 'barren-caves', title: 'Barren Caves', category: 'RoM' },
|
|
{ id: DEFAULT_TEST_DUNGEON_ID, title: DEFAULT_TEST_DUNGEON_TITLE, category: 'WoW' },
|
|
...(process.argv.includes('--historical-wailing') ? [{ id: 'wailing-caverns', title: 'Wailing Caverns', category: 'WoW' }] : []),
|
|
];
|
|
if (routeSelection === 'wailing-caverns' && !process.argv.includes('--historical-wailing')) {
|
|
throw new Error('Historical Wailing reproduction requires an explicit owner request and --historical-wailing.');
|
|
}
|
|
const routesToRun = routeSelection ? routes.filter((route) => route.id === routeSelection) : routes.filter((route) => route.id === DEFAULT_TEST_DUNGEON_ID);
|
|
const actionSlots = [
|
|
['primary:face-bottom', '1'], ['primary:face-left', '2'], ['primary:face-top', '3'],
|
|
['primary:face-right', '4'], ['primary:r1', '5'], ['primary:r2', '6'],
|
|
['primary:dpad-left', '7'], ['primary:dpad-right', '8'],
|
|
];
|
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
|
|
|
function distance3(left, right) {
|
|
if (!left || !right) return Number.POSITIVE_INFINITY;
|
|
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
|
|
}
|
|
|
|
function distancePlanar(left, right) {
|
|
if (!left || !right) return Number.POSITIVE_INFINITY;
|
|
return Math.hypot(left[0] - right[0], left[2] - right[2]);
|
|
}
|
|
|
|
function hasFinitePosition(position) {
|
|
return Array.isArray(position) && position.length >= 3 && position.every(Number.isFinite);
|
|
}
|
|
|
|
function escapeRegex(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function flatEffects(effects = []) {
|
|
return effects.flatMap((effect) => effect.kind === 'composite' ? flatEffects(effect.effects) : [effect]);
|
|
}
|
|
|
|
function actionPlanFromSnapshot(snapshot) {
|
|
const abilities = new Map(snapshot.abilities.map((ability) => [ability.id, ability]));
|
|
const bound = actionSlots.flatMap(([bindingId, key]) => {
|
|
const id = snapshot.actionBindings[bindingId];
|
|
const ability = id ? abilities.get(id) : null;
|
|
return ability && !ability.passive && (ability.unlockLevel ?? 1) <= snapshot.player.level
|
|
? [{ key, ability, effects: flatEffects(ability.effects) }]
|
|
: [];
|
|
});
|
|
const damage = bound.filter(({ ability, effects }) => ability.target === 'hostile'
|
|
&& effects.some((effect) => effect.kind === 'damage'))
|
|
.sort((left, right) => Number(right.ability.range?.max ?? 0) - Number(left.ability.range?.max ?? 0))[0]
|
|
?? bound.find(({ effects }) => effects.some((effect) => effect.kind === 'damage'));
|
|
const healing = bound.find(({ ability, effects }) => ability.target === 'friendly'
|
|
&& effects.some((effect) => effect.kind === 'heal'));
|
|
return {
|
|
damage: damage ? { key: damage.key, id: damage.ability.id, name: damage.ability.name,
|
|
target: damage.ability.target, range: damage.ability.range ?? null,
|
|
selectionReason: 'longest-range-bound-hostile-damage-ability' } : null,
|
|
healing: healing ? { key: healing.key, id: healing.ability.id, name: healing.ability.name,
|
|
target: healing.ability.target, range: healing.ability.range ?? null } : null,
|
|
bound: bound.map(({ key, ability }) => ({ key, id: ability.id, name: ability.name,
|
|
target: ability.target, range: ability.range ?? null })),
|
|
};
|
|
}
|
|
|
|
async function readLiveState(page, includeActionSetup = false) {
|
|
return page.evaluate((includeSetup) => window.nativeRouteStores().then(({ game, combat, party,
|
|
getMobPosition, mobPositionExports, getPartyPosition, partyPositionExports }) => {
|
|
const gameState = game.getState();
|
|
const combatState = combat.getState();
|
|
return {
|
|
now: Date.now(),
|
|
game: {
|
|
mobPositionExports,
|
|
partyPositionExports,
|
|
activeDungeonId: gameState.activeDungeonId,
|
|
gameMode: gameState.gameMode,
|
|
gmTestMode: gameState.gmTestMode,
|
|
bossTest: gameState.bossTest,
|
|
assetStatus: gameState.assetStatus,
|
|
activeSpawn: gameState.activeSpawn,
|
|
playerPosition: gameState.playerPosition,
|
|
playerNavigationPosition: gameState.playerNavigationPosition,
|
|
playerGrounded: gameState.playerGrounded,
|
|
cameraYaw: gameState.cameraYaw,
|
|
areaName: gameState.areaName,
|
|
distanceTravelled: gameState.distanceTravelled,
|
|
hasMoved: gameState.hasMoved,
|
|
overlay: gameState.overlay,
|
|
dungeonIntroActive: gameState.dungeonIntroActive,
|
|
},
|
|
player: {
|
|
classId: combatState.classId,
|
|
secondaryClassId: combatState.secondaryClassId,
|
|
level: combatState.level,
|
|
role: window.nativeRouteStoresRole ?? null,
|
|
statRuleset: combatState.statRuleset,
|
|
health: combatState.health,
|
|
maxHealth: combatState.maxHealth,
|
|
resource: combatState.resource,
|
|
maxResource: combatState.maxResource,
|
|
resourceType: combatState.resourceType,
|
|
resourceName: combatState.resourceName,
|
|
position: gameState.playerPosition,
|
|
selectedTargetId: combatState.selectedTargetId,
|
|
activeCast: combatState.activeCast?.abilityId ?? null,
|
|
cooldowns: combatState.cooldowns,
|
|
},
|
|
party: party.getState().members.map((member) => ({
|
|
id: member.id,
|
|
name: member.name,
|
|
classId: member.classId,
|
|
secondaryClassId: member.secondaryClassId,
|
|
role: member.role,
|
|
statRuleset: member.statRuleset,
|
|
health: member.health,
|
|
maxHealth: member.maxHealth,
|
|
resource: member.resource,
|
|
maxResource: member.maxResource,
|
|
resourceType: member.resourceType,
|
|
status: member.status,
|
|
activeCast: member.activeCast?.abilityId ?? null,
|
|
position: getPartyPosition?.(member.id) ?? null,
|
|
})),
|
|
mobs: Object.values(combatState.mobs).filter((mob) => !mob.despawned).map((mob) => ({
|
|
id: mob.id,
|
|
name: mob.name,
|
|
level: mob.level,
|
|
health: mob.health,
|
|
maxHealth: mob.maxHealth,
|
|
dead: mob.dead,
|
|
diedAt: mob.diedAt ?? null,
|
|
engaged: mob.engaged,
|
|
position: getMobPosition?.(mob.id) ?? mob.position ?? mob.homePosition,
|
|
homePosition: mob.homePosition,
|
|
attackRevision: mob.attackRevision,
|
|
abilityCastCounts: mob.abilityCastCounts,
|
|
activeCast: mob.activeCast ? { name: mob.activeCast.name, attackId: mob.activeCast.attackId } : null,
|
|
boss: mob.boss,
|
|
})),
|
|
...(includeSetup ? { abilities: combatState.abilities, actionBindings: combatState.actionBindings } : {}),
|
|
feedback: combatState.feedback ? { kind: combatState.feedback.kind,
|
|
message: combatState.feedback.message, abilityId: combatState.feedback.abilityId ?? null,
|
|
targetId: combatState.feedback.targetId ?? null } : null,
|
|
events: combatState.combatEvents.slice(-200),
|
|
};
|
|
}), includeActionSetup);
|
|
}
|
|
|
|
function liveMobs(snapshot) {
|
|
return snapshot.mobs.filter((mob) => !mob.dead && !mob.despawned);
|
|
}
|
|
|
|
function sourcePlayerEvent(event) {
|
|
return event.sourceActorId === '__player__';
|
|
}
|
|
|
|
async function selectHostileWithTab(page, routeResult, attempts = 10) {
|
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
await page.keyboard.press('Tab');
|
|
await page.waitForTimeout(80);
|
|
const snapshot = await readLiveState(page);
|
|
const target = snapshot.mobs.find((mob) => mob.id === snapshot.player.selectedTargetId
|
|
&& !mob.dead && !mob.despawned && !mob.boss && hasFinitePosition(mob.position));
|
|
routeResult.targeting.push({ at: snapshot.now, input: 'Tab', selectedTargetId: snapshot.player.selectedTargetId,
|
|
targetName: target?.name ?? null, targetDistance: target ? distance3(snapshot.player.position, target.position) : null });
|
|
if (target) return { snapshot, target };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function movementKeysTo(from, to, yaw) {
|
|
const dx = to[0] - from[0];
|
|
const dz = to[2] - from[2];
|
|
const magnitude = Math.hypot(dx, dz) || 1;
|
|
const worldX = dx / magnitude;
|
|
const worldZ = dz / magnitude;
|
|
const cosYaw = Math.cos(yaw);
|
|
const sinYaw = Math.sin(yaw);
|
|
const localX = -worldX * cosYaw + worldZ * sinYaw;
|
|
const localForward = worldX * sinYaw + worldZ * cosYaw;
|
|
const keys = [];
|
|
if (localForward > 0.32) keys.push('w');
|
|
else if (localForward < -0.32) keys.push('s');
|
|
if (localX > 0.32) keys.push('d');
|
|
else if (localX < -0.32) keys.push('a');
|
|
return keys;
|
|
}
|
|
|
|
async function walkToward(page, routeResult, destination, durationMs = 650) {
|
|
const before = await readLiveState(page);
|
|
const keys = movementKeysTo(before.player.position, destination, before.game.cameraYaw);
|
|
if (!keys.length) return null;
|
|
for (const key of keys) await page.keyboard.down(key);
|
|
try {
|
|
if (routeSelection && routesToRun.length !== 1) throw new Error(`Unknown native campaign route id: ${routeSelection}`);
|
|
await page.waitForTimeout(durationMs);
|
|
} finally {
|
|
for (const key of keys) await page.keyboard.up(key);
|
|
}
|
|
await page.waitForTimeout(150);
|
|
const after = await readLiveState(page);
|
|
const step = {
|
|
at: after.now,
|
|
keys: keys.map((key) => key.toUpperCase()),
|
|
durationMs,
|
|
from: before.player.position,
|
|
to: after.player.position,
|
|
destination,
|
|
planarDisplacement: distancePlanar(before.player.position, after.player.position),
|
|
spatialDisplacement: distance3(before.player.position, after.player.position),
|
|
gameDistanceTravelledDelta: after.game.distanceTravelled - before.game.distanceTravelled,
|
|
targetDistanceBefore: distance3(before.player.position, destination),
|
|
targetDistanceAfter: distance3(after.player.position, destination),
|
|
blocked: distancePlanar(before.player.position, after.player.position) < 0.08,
|
|
};
|
|
routeResult.movement.push(step);
|
|
return { snapshot: after, step };
|
|
}
|
|
|
|
async function performCast(page, routeResult, action, kind, intendedTargetId, reason) {
|
|
const before = await readLiveState(page);
|
|
const since = before.now - 1;
|
|
await page.keyboard.press(action.key);
|
|
const castWaitStarted = Date.now();
|
|
let after = await readLiveState(page);
|
|
const relevantEventsFor = (state) => state.events.filter((event) => event.occurredAt >= since
|
|
&& event.abilityId === action.id && sourcePlayerEvent(event)
|
|
&& (kind === 'damage' ? event.kind === 'damage' : event.kind === 'healing'));
|
|
while (Date.now() - castWaitStarted < 2_600) {
|
|
const relevant = relevantEventsFor(after);
|
|
if (relevant.some((event) => event.effectiveAmount > 0)
|
|
|| (!after.player.activeCast && Date.now() - castWaitStarted >= 650)) break;
|
|
await page.waitForTimeout(150);
|
|
after = await readLiveState(page);
|
|
}
|
|
const relevantEvents = relevantEventsFor(after);
|
|
const memberBefore = before.party.find((member) => member.id === intendedTargetId);
|
|
const memberAfter = after.party.find((member) => member.id === intendedTargetId);
|
|
const cast = {
|
|
at: before.now,
|
|
key: action.key,
|
|
abilityId: action.id,
|
|
abilityName: action.name,
|
|
kind,
|
|
reason,
|
|
intendedTargetId,
|
|
selectedTargetIdBefore: before.player.selectedTargetId,
|
|
targetHealthBefore: kind === 'healing'
|
|
? before.party.find((member) => member.id === intendedTargetId)?.health ?? null
|
|
: before.mobs.find((mob) => mob.id === intendedTargetId)?.health ?? null,
|
|
targetHealthAfter: kind === 'healing'
|
|
? after.party.find((member) => member.id === intendedTargetId)?.health ?? null
|
|
: after.mobs.find((mob) => mob.id === intendedTargetId)?.health ?? null,
|
|
playerPositionAtInput: before.player.position,
|
|
allyPositionAtInput: memberBefore?.position ?? null,
|
|
allyDistanceAtInput: memberBefore?.position
|
|
? distance3(before.player.position, memberBefore.position) : null,
|
|
allyDistanceAfterCast: memberAfter?.position
|
|
? distance3(after.player.position, memberAfter.position) : null,
|
|
castWaitMs: Date.now() - castWaitStarted,
|
|
resourceBefore: before.player.resource,
|
|
resourceAfter: after.player.resource,
|
|
resolvedEvents: relevantEvents.map((event) => ({ kind: event.kind, targetActorId: event.targetActorId,
|
|
effectiveAmount: event.effectiveAmount, overheal: event.overheal, school: event.school ?? null,
|
|
abilityId: event.abilityId, occurredAt: event.occurredAt })),
|
|
actionResolved: relevantEvents.some((event) => event.effectiveAmount > 0),
|
|
feedback: after.feedback,
|
|
};
|
|
routeResult.actions.push(cast);
|
|
return { before, after, cast };
|
|
}
|
|
|
|
async function selectPartyMember(page, member) {
|
|
const label = new RegExp(`^Target ${escapeRegex(member.name)},`);
|
|
// Accessible target labels are unique for native companions; getByRole keeps
|
|
// the test on the same clickable party-frame that a player would use.
|
|
await page.getByRole('button', { name: label }).click();
|
|
}
|
|
|
|
async function rosterContinueHitTest(page, viewport) {
|
|
return page.evaluate((viewportSize) => {
|
|
const button = document.querySelector('.roster-enter');
|
|
const toggle = document.querySelector('.single-context-toggle');
|
|
if (!button) throw new Error('Roster Continue button is missing.');
|
|
const buttonRect = button.getBoundingClientRect();
|
|
const toggleRect = toggle?.getBoundingClientRect() ?? null;
|
|
const overlaps = Boolean(toggleRect
|
|
&& buttonRect.left < toggleRect.right && buttonRect.right > toggleRect.left
|
|
&& buttonRect.top < toggleRect.bottom && buttonRect.bottom > toggleRect.top);
|
|
const center = { x: buttonRect.left + buttonRect.width / 2, y: buttonRect.top + buttonRect.height / 2 };
|
|
const hit = document.elementFromPoint(center.x, center.y);
|
|
return {
|
|
viewport: viewportSize,
|
|
buttonRect: { x: buttonRect.x, y: buttonRect.y, width: buttonRect.width, height: buttonRect.height },
|
|
toggleRect: toggleRect ? { x: toggleRect.x, y: toggleRect.y,
|
|
width: toggleRect.width, height: toggleRect.height } : null,
|
|
overlaps,
|
|
centerHit: hit?.className?.toString?.() ?? hit?.tagName ?? null,
|
|
centerHitIsContinue: Boolean(hit && (hit === button || button.contains(hit))),
|
|
};
|
|
}, viewport);
|
|
}
|
|
|
|
async function prepareCharacter(page) {
|
|
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: 'RoM' }).click();
|
|
await page.locator('.class-option').filter({ hasText: /^Priest/ }).click();
|
|
await page.getByRole('button', { name: /Customize & name/ }).click();
|
|
await page.getByLabel('Character name').fill('RoutePriestQA');
|
|
await page.getByRole('button', { name: /Create character/ }).click();
|
|
await page.locator('.character-select-screen').waitFor();
|
|
const profile = await page.evaluate(async () => {
|
|
const { shell } = await window.nativeRouteStores();
|
|
const state = shell.getState();
|
|
const character = state.characters.find((entry) => entry.id === state.selectedCharacterId);
|
|
if (!character || character.classId !== 'rom-priest') throw new Error('UI-created RoM Priest profile is missing.');
|
|
shell.setState({ activeCharacter: character });
|
|
shell.getState().saveActiveProgression(30, 0, character.talentRanks);
|
|
shell.setState({ activeCharacter: null });
|
|
if (!shell.getState().chooseRomSecondaryClass('rom-knight')) throw new Error('Priest/Knight pairing was not accepted.');
|
|
const updated = shell.getState().characters.find((entry) => entry.id === character.id);
|
|
return { id: updated.id, name: updated.name, classId: updated.classId,
|
|
secondaryClassId: updated.secondaryClassId, level: updated.level, categoryId: updated.categoryId };
|
|
});
|
|
assert.equal(profile.classId, 'rom-priest');
|
|
assert.equal(profile.secondaryClassId, 'rom-knight');
|
|
assert.equal(profile.level, 30);
|
|
await page.waitForTimeout(1_500);
|
|
await page.screenshot({ path: path.join(outputDirectory, 'roster-priest-knight-level-30.png') });
|
|
const desktopRosterHit = await rosterContinueHitTest(page, { width: 1440, height: 900 });
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.waitForTimeout(300);
|
|
await page.screenshot({ path: path.join(outputDirectory, 'roster-compact-priest-knight-level-30.png') });
|
|
const compactRosterHit = await rosterContinueHitTest(page, { width: 390, height: 844 });
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.waitForTimeout(300);
|
|
const rosterUi = { desktop: desktopRosterHit, compact: compactRosterHit,
|
|
forceClickUsed: false };
|
|
if (!baseline) {
|
|
assert.equal(desktopRosterHit.overlaps, false, 'Desktop roster toggle does not overlap Continue.');
|
|
assert.equal(desktopRosterHit.centerHitIsContinue, true, 'Desktop Continue button owns its center hit point.');
|
|
assert.equal(compactRosterHit.overlaps, false, 'Compact roster toggle does not overlap Continue.');
|
|
assert.equal(compactRosterHit.centerHitIsContinue, true, 'Compact Continue button owns its center hit point.');
|
|
}
|
|
if (desktopRosterHit.centerHitIsContinue) {
|
|
await page.locator('.roster-enter').click();
|
|
} else if (baseline) {
|
|
// A baseline with the known floating-toggle obstruction may continue via
|
|
// a forced pointer action for diagnostics, and records that exception.
|
|
rosterUi.forceClickUsed = true;
|
|
await page.locator('.roster-enter').click({ force: true });
|
|
} else {
|
|
throw new Error('Final-build roster Continue button is obscured at its center hit point.');
|
|
}
|
|
await page.locator('.main-menu-screen').waitFor();
|
|
return { profile, rosterUi };
|
|
}
|
|
|
|
async function enterCampaignDungeon(page, route, isFirst, minimumLevel = 30) {
|
|
if (!isFirst) {
|
|
await page.keyboard.press('Escape');
|
|
await page.getByRole('button', { name: 'Main menu', exact: true }).waitFor({ state: 'visible' });
|
|
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
|
|
await page.locator('.main-menu-screen').waitFor();
|
|
}
|
|
await page.getByRole('button', { name: /^Dungeons/ }).click();
|
|
await page.locator('.dungeon-select-screen').waitFor();
|
|
await page.locator('.dungeon-category-tab').filter({ hasText: route.category }).click();
|
|
const exactTitle = new RegExp(`^${escapeRegex(route.title)}$`, 'i');
|
|
const row = page.locator('.dungeon-select-row').filter({
|
|
has: page.locator('strong').filter({ hasText: exactTitle }),
|
|
});
|
|
const exactRowCount = await row.count();
|
|
assert.equal(exactRowCount, 1, `${route.title} has one exact source campaign row in ${route.category}`);
|
|
await row.waitFor({ state: 'visible' });
|
|
const selectedRowTitle = (await row.locator('strong').innerText()).trim();
|
|
assert.equal(selectedRowTitle, route.title, `${route.title} campaign selection is exact`);
|
|
await page.screenshot({ path: path.join(outputDirectory, `${route.id}-campaign-picker.png`) });
|
|
await row.click();
|
|
if (await page.getByRole('alertdialog').count()) {
|
|
const confirmDownload = page.getByRole('button', { name: 'Download and continue', exact: true });
|
|
if (await confirmDownload.count()) await confirmDownload.click();
|
|
} else {
|
|
await page.locator('.dungeon-select-enter').click();
|
|
if (await page.getByRole('alertdialog').count()) {
|
|
await page.getByRole('button', { name: 'Download and continue', exact: true }).click();
|
|
}
|
|
}
|
|
await page.locator('.dungeon-role-option--healer').waitFor({ state: 'visible', timeout: 180_000 });
|
|
await page.locator('.dungeon-role-option--healer').click();
|
|
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
|
|
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 180_000 }).catch(() => {});
|
|
await page.waitForFunction(async (expectedId) => {
|
|
const { game } = await window.nativeRouteStores();
|
|
const state = game.getState();
|
|
return state.activeDungeonId === expectedId && state.assetStatus === 'ready';
|
|
}, route.id, { timeout: 240_000 });
|
|
await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached', timeout: 15_000 }).catch(() => {});
|
|
await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur());
|
|
const state = await readLiveState(page);
|
|
assert.equal(state.game.activeDungeonId, route.id, `${route.title} entered via the selected campaign route`);
|
|
assert.equal(state.game.gameMode, 'dungeon');
|
|
assert.equal(state.game.gmTestMode, false, 'campaign entry is not GM mode');
|
|
assert.equal(state.game.bossTest, null, 'campaign entry is not Boss Testing');
|
|
assert.equal(state.player.classId, 'rom-priest');
|
|
assert.equal(state.player.secondaryClassId, 'rom-knight');
|
|
assert(state.player.level >= minimumLevel,
|
|
`${route.title} entry level remains at least the UI-created profile's level ${minimumLevel}`);
|
|
assert.equal(state.party.length, 4, 'the UI-created Priest has four NPC companions');
|
|
assert(state.party.every((member) => member.classId.startsWith('rom-') && member.statRuleset === 'rom'),
|
|
'all four companions use native RoM identities and rules');
|
|
await page.screenshot({ path: path.join(outputDirectory, `${route.id}-entrance.png`) });
|
|
return state;
|
|
}
|
|
|
|
async function runRouteSegment(page, route, entryState, profile) {
|
|
const result = {
|
|
dungeonId: route.id,
|
|
title: route.title,
|
|
coverage: 'campaign entrance through first live in-scene pack/encounter segment; no full route or boss-clear claim',
|
|
enteredThrough: 'normal Dungeons picker and healer role selection',
|
|
bossTesting: false,
|
|
profile,
|
|
playerAtEntry: entryState.player,
|
|
partyAtEntry: entryState.party,
|
|
dungeonEntry: entryState.game,
|
|
startingPosition: entryState.player.position,
|
|
actionPlan: actionPlanFromSnapshot(entryState),
|
|
movement: [],
|
|
healPursuits: [],
|
|
targeting: [],
|
|
actions: [],
|
|
samples: [],
|
|
observedCombatEvents: [],
|
|
screenshots: {
|
|
entrance: `${route.id}-entrance.png`,
|
|
campaignPicker: `${route.id}-campaign-picker.png`,
|
|
walk: `${route.id}-keyboard-traversal.png`,
|
|
middle: `${route.id}-encounter-midpoint.png`,
|
|
endpoint: `${route.id}-segment-end.png`,
|
|
},
|
|
segmentStartedAt: Date.now(),
|
|
};
|
|
const seenEventKeys = new Set(entryState.events.map((event) => [event.occurredAt, event.sourceActorId,
|
|
event.targetActorId, event.abilityId, event.kind, event.effectiveAmount].join('|')));
|
|
const observedDeadMobs = new Map();
|
|
const collectCombatEvents = (snapshot) => {
|
|
for (const mob of snapshot.mobs.filter((entry) => entry.dead)) observedDeadMobs.set(mob.id, mob.name);
|
|
for (const event of snapshot.events) {
|
|
const key = [event.occurredAt, event.sourceActorId, event.targetActorId,
|
|
event.abilityId, event.kind, event.effectiveAmount].join('|');
|
|
if (seenEventKeys.has(key)) continue;
|
|
seenEventKeys.add(key);
|
|
result.observedCombatEvents.push(event);
|
|
}
|
|
};
|
|
const initialMobs = liveMobs(entryState).map((mob) => ({
|
|
id: mob.id, name: mob.name, level: mob.level, position: mob.position,
|
|
distanceFromEntrance: distance3(entryState.player.position, mob.position),
|
|
boss: mob.boss,
|
|
})).sort((left, right) => left.distanceFromEntrance - right.distanceFromEntrance);
|
|
result.sceneMobsAtEntry = initialMobs;
|
|
const plan = result.actionPlan;
|
|
if (!plan.damage) {
|
|
result.bindingDiagnostic = {
|
|
actionBindings: entryState.actionBindings,
|
|
computedActionPlan: plan,
|
|
playerLevel: entryState.player.level,
|
|
boundSlots: actionSlots.map(([bindingId, key]) => ({ bindingId, key,
|
|
abilityId: entryState.actionBindings[bindingId] ?? null })),
|
|
rawBoundAbilities: actionSlots.map(([bindingId, key]) => {
|
|
const id = entryState.actionBindings[bindingId];
|
|
const ability = entryState.abilities.find((candidate) => candidate.id === id);
|
|
return { key, id, passive: ability?.passive, unlockLevel: ability?.unlockLevel,
|
|
target: ability?.target, rawEffects: ability?.effects,
|
|
flattenedEffects: ability ? flatEffects(ability.effects).map((effect) => effect.kind) : null };
|
|
}),
|
|
abilityCount: entryState.abilities.length,
|
|
availableAbilities: entryState.abilities.filter((ability) => !ability.passive
|
|
&& (ability.unlockLevel ?? 1) <= entryState.player.level).map((ability) => ({ id: ability.id,
|
|
name: ability.name, target: ability.target, unlockLevel: ability.unlockLevel,
|
|
effects: flatEffects(ability.effects).map((effect) => effect.kind) })),
|
|
};
|
|
fs.writeFileSync(path.join(outputDirectory, `${route.id}-binding-diagnostic.json`),
|
|
`${JSON.stringify(result.bindingDiagnostic, null, 2)}\n`);
|
|
throw new Error(`${route.title}: no native damage ability is bound to a normal keyboard slot. Details: ${JSON.stringify({
|
|
actionBindings: result.bindingDiagnostic.actionBindings,
|
|
availableAbilities: result.bindingDiagnostic.availableAbilities.slice(0, 18),
|
|
})}`);
|
|
}
|
|
result.mappedHealAvailable = Boolean(plan.healing);
|
|
|
|
let snapshot = await readLiveState(page);
|
|
let selected = await selectHostileWithTab(page, result, 10);
|
|
if (selected) snapshot = selected.snapshot;
|
|
let candidate = (selected?.target && hasFinitePosition(selected.target.position) ? selected.target : null)
|
|
?? liveMobs(snapshot)
|
|
.map((mob) => ({ ...mob, distance: distance3(snapshot.player.position, mob.position) }))
|
|
.filter((mob) => mob.distance < 120 && !mob.boss && hasFinitePosition(mob.position))
|
|
.sort((left, right) => left.distance - right.distance)[0]
|
|
?? null;
|
|
if (!candidate) {
|
|
const diagnostic = await readLiveState(page, true);
|
|
fs.writeFileSync(path.join(outputDirectory, `${route.id}-target-diagnostic.json`),
|
|
`${JSON.stringify({ targeting: result.targeting, sceneMobsAtEntry: result.sceneMobsAtEntry,
|
|
player: diagnostic.player, mobs: diagnostic.mobs }, null, 2)}\n`);
|
|
throw new Error(`${route.title}: no live in-scene pack was available near the campaign entrance.`);
|
|
}
|
|
assert(hasFinitePosition(candidate.position), `${route.title}: candidate mob transform must be finite before movement.`);
|
|
result.firstPackTarget = { id: candidate.id, name: candidate.name,
|
|
position: candidate.position, transformAvailable: hasFinitePosition(candidate.position),
|
|
distanceAtSelection: distance3(snapshot.player.position, candidate.position), boss: candidate.boss };
|
|
|
|
const damageRange = Number(plan.damage.range?.max ?? 40);
|
|
let walked = 0;
|
|
let stuckSteps = 0;
|
|
for (let step = 0; step < 18; step += 1) {
|
|
const current = await readLiveState(page);
|
|
let freshTarget = current.mobs.find((mob) => mob.id === candidate.id && !mob.dead
|
|
&& !mob.despawned && !mob.boss && hasFinitePosition(mob.position));
|
|
if (!freshTarget) {
|
|
freshTarget = liveMobs(current).filter((mob) => !mob.boss && hasFinitePosition(mob.position))
|
|
.map((mob) => ({ ...mob, distance: distance3(current.player.position, mob.position) }))
|
|
.filter((mob) => mob.distance < 120).sort((left, right) => left.distance - right.distance)[0];
|
|
}
|
|
if (!freshTarget) break;
|
|
candidate = freshTarget;
|
|
const targetPosition = freshTarget.position;
|
|
const dist = distance3(current.player.position, targetPosition);
|
|
if (dist <= Math.max(7, damageRange - 3) && walked >= 6) break;
|
|
const moved = await walkToward(page, result, targetPosition, 700);
|
|
if (!moved) break;
|
|
walked += moved.step.planarDisplacement;
|
|
stuckSteps = moved.step.blocked ? stuckSteps + 1 : 0;
|
|
snapshot = moved.snapshot;
|
|
if (stuckSteps >= 3) break;
|
|
}
|
|
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.walk) });
|
|
result.keyboardTravelDistance = walked;
|
|
result.positionAfterTraversal = snapshot.player.position;
|
|
result.distanceTravelledAtEntry = entryState.game.distanceTravelled;
|
|
result.distanceTravelledAfterTraversal = snapshot.game.distanceTravelled;
|
|
|
|
await page.keyboard.press('F1');
|
|
result.partyCommands = [{ at: Date.now(), key: 'F1', command: 'Attack', targetId: snapshot.player.selectedTargetId }];
|
|
await page.waitForTimeout(900);
|
|
await page.keyboard.press('F2');
|
|
result.partyCommands.push({ at: Date.now(), key: 'F2', command: 'Defend', reason: 'hold the initial encounter near the healer' });
|
|
const targetAgain = await selectHostileWithTab(page, result, 6);
|
|
if (targetAgain) {
|
|
snapshot = targetAgain.snapshot;
|
|
if (targetAgain.target.position) candidate = targetAgain.target;
|
|
}
|
|
const endAt = Date.now() + routeDurationMs;
|
|
let lastDamageAt = 0;
|
|
let lastHealAt = 0;
|
|
let middleCaptured = false;
|
|
let lastSampleAt = 0;
|
|
let noTargetStreak = 0;
|
|
while (Date.now() < endAt) {
|
|
snapshot = await readLiveState(page);
|
|
collectCombatEvents(snapshot);
|
|
if (snapshot.player.health <= 0) break;
|
|
if (lastSampleAt === 0 || snapshot.now - lastSampleAt >= 1_000) {
|
|
result.samples.push({ at: snapshot.now, player: snapshot.player,
|
|
party: snapshot.party, mobs: snapshot.mobs.map((mob) => ({ id: mob.id, name: mob.name,
|
|
health: mob.health, maxHealth: mob.maxHealth, dead: mob.dead, engaged: mob.engaged,
|
|
activeCast: mob.activeCast, position: mob.position })) });
|
|
lastSampleAt = snapshot.now;
|
|
}
|
|
|
|
const currentTarget = snapshot.mobs.find((mob) => mob.id === snapshot.player.selectedTargetId
|
|
&& !mob.dead && !mob.despawned && !mob.boss && hasFinitePosition(mob.position));
|
|
const wounded = snapshot.party.filter((member) => member.health > 0 && member.health < member.maxHealth - 1)
|
|
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth);
|
|
if (wounded.length && plan.healing && Date.now() - lastHealAt > 1_100) {
|
|
let member = wounded[0];
|
|
const healRange = Number(plan.healing.range?.max ?? 25);
|
|
const pursuit = { at: Date.now(), memberId: member.id, memberName: member.name,
|
|
startPosition: snapshot.player.position, startAllyPosition: member.position,
|
|
startDistance: hasFinitePosition(member.position)
|
|
? distance3(snapshot.player.position, member.position) : null,
|
|
movement: [], outcome: 'position-unavailable' };
|
|
for (let step = 0; step < 6 && !snapshot.player.activeCast; step += 1) {
|
|
const refreshed = await readLiveState(page);
|
|
const freshMember = refreshed.party.find((entry) => entry.id === member.id);
|
|
if (!freshMember || freshMember.health <= 0 || !hasFinitePosition(freshMember.position)) {
|
|
snapshot = refreshed;
|
|
pursuit.outcome = 'member-no-longer-live-or-position-unavailable';
|
|
break;
|
|
}
|
|
snapshot = refreshed;
|
|
member = freshMember;
|
|
const distance = distance3(snapshot.player.position, member.position);
|
|
pursuit.lastAllyPosition = member.position;
|
|
pursuit.lastDistance = distance;
|
|
if (distance <= healRange - 1.5) {
|
|
pursuit.outcome = 'within-heal-range';
|
|
break;
|
|
}
|
|
const moved = await walkToward(page, result, member.position, 600);
|
|
if (!moved || moved.step.planarDisplacement < 0.08) {
|
|
pursuit.outcome = 'movement-blocked-or-aligned';
|
|
break;
|
|
}
|
|
pursuit.movement.push(moved.step);
|
|
snapshot = moved.snapshot;
|
|
}
|
|
snapshot = await readLiveState(page);
|
|
member = snapshot.party.find((entry) => entry.id === member.id) ?? member;
|
|
pursuit.endPosition = snapshot.player.position;
|
|
pursuit.endAllyPosition = member.position ?? null;
|
|
pursuit.endDistance = hasFinitePosition(member.position)
|
|
? distance3(snapshot.player.position, member.position) : null;
|
|
if (pursuit.endDistance !== null && pursuit.endDistance <= healRange - 1.5) {
|
|
pursuit.outcome = 'within-heal-range';
|
|
}
|
|
if (pursuit.outcome !== 'within-heal-range') {
|
|
result.healPursuits.push(pursuit);
|
|
lastHealAt = Date.now();
|
|
await page.waitForTimeout(200);
|
|
continue;
|
|
}
|
|
const targetBefore = snapshot.player.selectedTargetId;
|
|
await selectPartyMember(page, member);
|
|
const cast = await performCast(page, result, plan.healing, 'healing', member.id,
|
|
`heal-lowest-wounded-active-NPC (${Math.round(member.health / member.maxHealth * 100)}% before cast)`);
|
|
pursuit.castActionIndex = result.actions.length - 1;
|
|
pursuit.outcome = cast.cast.actionResolved ? 'cast-effective-heal'
|
|
: cast.cast.actionAccepted ? 'cast-accepted-without-observed-healing' : 'cast-not-accepted';
|
|
result.healPursuits.push(pursuit);
|
|
lastHealAt = Date.now();
|
|
if (!middleCaptured) {
|
|
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.middle) });
|
|
middleCaptured = true;
|
|
}
|
|
result.actions[result.actions.length - 1].hostileTargetBeforeAllySelection = targetBefore;
|
|
if (cast.cast.actionResolved) result.effectiveNativeHeals = (result.effectiveNativeHeals ?? 0) + 1;
|
|
noTargetStreak = 0;
|
|
continue;
|
|
}
|
|
|
|
let target = currentTarget;
|
|
if (!target) {
|
|
const selectedTarget = await selectHostileWithTab(page, result, 6);
|
|
target = selectedTarget?.target ?? null;
|
|
if (selectedTarget) snapshot = selectedTarget.snapshot;
|
|
}
|
|
if (!target) {
|
|
noTargetStreak += 1;
|
|
const available = liveMobs(snapshot).filter((mob) => !mob.boss).map((mob) => ({ ...mob,
|
|
distance: distance3(snapshot.player.position, mob.position) }))
|
|
.filter((mob) => mob.distance < 75 && hasFinitePosition(mob.position))
|
|
.sort((left, right) => left.distance - right.distance)[0];
|
|
if (available && noTargetStreak < 3) {
|
|
const moved = await walkToward(page, result, available.position, 650);
|
|
if (moved && moved.step.planarDisplacement > 0.1) {
|
|
await selectHostileWithTab(page, result, 6);
|
|
continue;
|
|
}
|
|
}
|
|
await page.waitForTimeout(350);
|
|
continue;
|
|
}
|
|
noTargetStreak = 0;
|
|
|
|
const targetDistance = distance3(snapshot.player.position, target.position);
|
|
const targetRange = Math.max(0, Number(plan.damage.range?.max ?? 40) - 1.5);
|
|
if (targetDistance > targetRange || !target.position) {
|
|
const destination = target.position ?? (target.id === candidate.id ? candidate.position : null);
|
|
if (!destination) {
|
|
result.targetingWithoutPosition ??= [];
|
|
result.targetingWithoutPosition.push({ at: snapshot.now, id: target.id, name: target.name });
|
|
const cast = await performCast(page, result, plan.damage, 'damage', target.id,
|
|
'keyboard damage attempt after Tab selection where mob transform was not readable');
|
|
if (cast.cast.actionResolved) result.effectivePlayerDamageCasts = (result.effectivePlayerDamageCasts ?? 0) + 1;
|
|
continue;
|
|
}
|
|
const moved = await walkToward(page, result, destination, 550);
|
|
if (moved && moved.step.planarDisplacement > 0.08) continue;
|
|
}
|
|
if (Date.now() - lastDamageAt > 700) {
|
|
const cast = await performCast(page, result, plan.damage, 'damage', target.id,
|
|
'player keyboard cast against the Tab-selected live campaign mob');
|
|
lastDamageAt = Date.now();
|
|
if (!middleCaptured) {
|
|
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.middle) });
|
|
middleCaptured = true;
|
|
}
|
|
if (cast.cast.actionResolved) result.effectivePlayerDamageCasts = (result.effectivePlayerDamageCasts ?? 0) + 1;
|
|
} else {
|
|
await page.waitForTimeout(250);
|
|
}
|
|
}
|
|
|
|
const final = await readLiveState(page);
|
|
collectCombatEvents(final);
|
|
result.segmentEndedAt = Date.now();
|
|
result.endingPosition = final.player.position;
|
|
result.endingDistanceTravelled = final.game.distanceTravelled;
|
|
result.playerAtEndpoint = final.player;
|
|
result.partyAtEndpoint = final.party;
|
|
result.sceneMobsAtEndpoint = final.mobs;
|
|
const playerEvents = result.observedCombatEvents.filter(sourcePlayerEvent);
|
|
const healedPartyIds = new Set(entryState.party.map((member) => member.id));
|
|
const effectiveHealEvents = playerEvents.filter((event) => event.kind === 'healing'
|
|
&& healedPartyIds.has(event.targetActorId) && event.effectiveAmount > 0);
|
|
const effectiveDamageEvents = playerEvents.filter((event) => event.kind === 'damage' && event.effectiveAmount > 0);
|
|
for (let index = 0; index < result.actions.length; index += 1) {
|
|
const action = result.actions[index];
|
|
const nextSameActionAt = result.actions.slice(index + 1)
|
|
.find((next) => next.abilityId === action.abilityId)?.at ?? Number.POSITIVE_INFINITY;
|
|
const actionEvents = playerEvents.filter((event) => event.abilityId === action.abilityId
|
|
&& event.targetActorId === action.intendedTargetId
|
|
&& event.occurredAt >= action.at - 20 && event.occurredAt < nextSameActionAt);
|
|
const effectEvents = actionEvents.filter((event) => event.kind === action.kind);
|
|
action.resolvedEvents = effectEvents.map((event) => ({ kind: event.kind,
|
|
targetActorId: event.targetActorId, effectiveAmount: event.effectiveAmount,
|
|
overheal: event.overheal, school: event.school ?? null, abilityId: event.abilityId,
|
|
occurredAt: event.occurredAt }));
|
|
action.actionAccepted = actionEvents.some((event) => event.kind === 'cast'
|
|
&& ['started', 'completed'].includes(event.outcome));
|
|
action.actionResolved = effectEvents.some((event) => event.effectiveAmount > 0);
|
|
}
|
|
result.effectiveNativeHeals = effectiveHealEvents.length;
|
|
result.totalEffectiveNativeHealing = effectiveHealEvents.reduce((sum, event) => sum + event.effectiveAmount, 0);
|
|
result.effectivePlayerDamageEvents = effectiveDamageEvents.length;
|
|
result.totalEffectivePlayerDamage = effectiveDamageEvents.reduce((sum, event) => sum + event.effectiveAmount, 0);
|
|
result.coverageEndpoint = {
|
|
endpoint: final.game.areaName,
|
|
endpointPosition: final.player.position,
|
|
accumulatedTravelDistance: final.game.distanceTravelled - entryState.game.distanceTravelled,
|
|
keyboardStepDisplacement: result.movement.reduce((sum, step) => sum + step.planarDisplacement, 0),
|
|
totalKeyboardKeyDowns: result.movement.length,
|
|
damageEvents: result.effectivePlayerDamageEvents,
|
|
totalEffectivePlayerDamage: result.totalEffectivePlayerDamage,
|
|
healingEvents: effectiveHealEvents.length,
|
|
totalEffectiveNativeHealing: result.totalEffectiveNativeHealing,
|
|
observedSchools: [...new Set(result.observedCombatEvents.map((event) => event.school).filter(Boolean))].sort(),
|
|
eventCountsByKind: Object.fromEntries([...new Set(result.observedCombatEvents.map((event) => event.kind))]
|
|
.map((kind) => [kind, result.observedCombatEvents.filter((event) => event.kind === kind).length])),
|
|
killedMobNames: final.mobs.filter((mob) => mob.dead).map((mob) => mob.name),
|
|
observedDeadMobNames: [...new Set(observedDeadMobs.values())].sort(),
|
|
observedDeadMobCount: observedDeadMobs.size,
|
|
partyDeaths: final.party.filter((member) => member.health <= 0).map((member) => member.name),
|
|
};
|
|
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.endpoint) });
|
|
result.pageErrors = [];
|
|
fs.writeFileSync(path.join(outputDirectory, `${route.id}-route.json`), `${JSON.stringify(result, null, 2)}\n`);
|
|
return result;
|
|
}
|
|
|
|
(async () => {
|
|
const { createGameServer } = await import('../server/server.mjs');
|
|
const runtime = createGameServer({ dataDir: dataDirectory, enableShelvedModes: false });
|
|
await new Promise((resolve, reject) => {
|
|
runtime.server.once('error', reject);
|
|
runtime.server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
const address = runtime.server.address();
|
|
const baseUrl = `http://127.0.0.1:${address.port}`;
|
|
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
|
page.setDefaultTimeout(120_000);
|
|
const pageErrors = [];
|
|
const consoleErrors = [];
|
|
const completedRoutes = [];
|
|
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() });
|
|
});
|
|
await page.addInitScript(({ urls }) => {
|
|
window.nativeRouteKeyLog = [];
|
|
window.addEventListener('keydown', (event) => {
|
|
if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'Tab', 'F1', 'Digit1', 'Digit2', 'Digit3', 'Digit4',
|
|
'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Escape'].includes(event.code)) {
|
|
window.nativeRouteKeyLog.push({ at: Date.now(), code: event.code, repeat: event.repeat });
|
|
}
|
|
}, true);
|
|
window.nativeRouteStores = async () => {
|
|
const modules = await Promise.all(urls.map((url) => import(url)));
|
|
const namedExports = modules.flatMap((module) => Object.entries(module));
|
|
const exports = namedExports.map(([, value]) => value);
|
|
const stores = exports.filter((value) => value && typeof value.getState === 'function');
|
|
// Vite renames this exported registry function. Match the inspected pure
|
|
// getMobRuntimeTransform body: it only reads the private Map and copies
|
|
// position/yaw, returning null for an unmounted actor.
|
|
const transformExport = namedExports.find(([, value]) => {
|
|
if (typeof value !== 'function') return false;
|
|
const source = Function.prototype.toString.call(value);
|
|
return source.includes('.mounted') && source.includes('.value')
|
|
&& source.includes('position:') && source.includes('yaw:')
|
|
&& source.includes('...') && source.includes('null');
|
|
});
|
|
const readTransform = transformExport?.[1] ?? null;
|
|
// Source-mapped to getPartyRuntimePosition: it reads the party position
|
|
// registry and returns a defensive copy or null, with no state mutation.
|
|
const partyPositionExport = namedExports.find(([, value]) => {
|
|
if (typeof value !== 'function') return false;
|
|
const source = Function.prototype.toString.call(value);
|
|
return /\.get\(/.test(source)
|
|
&& /return\s+[A-Za-z_$][\w$]*\s*\?\s*\[\s*\.\.\.\s*[A-Za-z_$][\w$]*\s*\]\s*:\s*null/.test(source);
|
|
});
|
|
const readPartyPosition = partyPositionExport?.[1] ?? null;
|
|
return {
|
|
getMobPosition: readTransform ? (id) => readTransform(id)?.position ?? null : null,
|
|
mobPositionExports: transformExport ? [transformExport[0]] : [],
|
|
getPartyPosition: readPartyPosition,
|
|
partyPositionExports: partyPositionExport ? [partyPositionExport[0]] : [],
|
|
game: stores.find((store) => typeof store.getState()?.relocatePlayer === 'function'),
|
|
shell: stores.find((store) => typeof store.getState()?.saveActiveProgression === 'function'),
|
|
combat: stores.find((store) => typeof store.getState()?.castAbility === 'function'),
|
|
party: stores.find((store) => typeof store.getState()?.setCommand === 'function'
|
|
&& Array.isArray(store.getState()?.members)),
|
|
};
|
|
};
|
|
}, { urls: moduleUrls });
|
|
|
|
try {
|
|
if (moduleFiles.length < 3) throw new Error(`Expected built game assets were not found in dist/assets: ${moduleFiles.join(', ')}`);
|
|
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
|
|
const { profile, rosterUi } = await prepareCharacter(page);
|
|
for (let index = 0; index < routesToRun.length; index += 1) {
|
|
const route = routesToRun[index];
|
|
await enterCampaignDungeon(page, route, index === 0, profile.level);
|
|
await page.evaluate(() => { window.nativeRouteStoresRole = 'healer'; });
|
|
const entry = await readLiveState(page, true);
|
|
const routeResult = await runRouteSegment(page, route, entry, profile);
|
|
routeResult.pageErrors = pageErrors.slice();
|
|
routeResult.consoleErrors = consoleErrors.slice();
|
|
routeResult.keyboardEvents = await page.evaluate(() => window.nativeRouteKeyLog.slice(-500));
|
|
fs.writeFileSync(path.join(outputDirectory, `${route.id}-route.json`), `${JSON.stringify(routeResult, null, 2)}\n`);
|
|
completedRoutes.push(routeResult);
|
|
}
|
|
assert.deepEqual(pageErrors, [], `No browser page exceptions: ${JSON.stringify(pageErrors)}`);
|
|
fs.writeFileSync(path.join(outputDirectory, 'results.json'), `${JSON.stringify({
|
|
status: 'complete',
|
|
snapshotKind: baseline ? 'baseline-existing-dist; final-build rerun required' : 'final-build route QA',
|
|
buildAssets: moduleFiles,
|
|
routeDurationMs,
|
|
profile,
|
|
rosterUi,
|
|
routes: completedRoutes.map((route) => ({
|
|
dungeonId: route.dungeonId,
|
|
coverage: route.coverage,
|
|
coverageEndpoint: route.coverageEndpoint,
|
|
mappedHealAvailable: route.mappedHealAvailable,
|
|
effectiveNativeHeals: route.effectiveNativeHeals ?? 0,
|
|
totalEffectiveNativeHealing: route.totalEffectiveNativeHealing ?? 0,
|
|
effectivePlayerDamageEvents: route.effectivePlayerDamageEvents ?? 0,
|
|
totalEffectivePlayerDamage: route.totalEffectivePlayerDamage ?? 0,
|
|
coverageEndpoint: route.coverageEndpoint,
|
|
})),
|
|
pageErrors,
|
|
consoleErrors,
|
|
}, null, 2)}\n`);
|
|
console.log(`Native campaign route QA ${baseline ? 'baseline' : 'complete'}: ${outputDirectory}`);
|
|
} catch (error) {
|
|
await page.screenshot({ path: path.join(outputDirectory, 'failure.png') }).catch(() => {});
|
|
const failed = { status: 'failed', snapshotKind: baseline ? 'baseline-existing-dist' : 'final-build route QA',
|
|
buildAssets: moduleFiles, error: error.stack ?? error.message, pageErrors, consoleErrors,
|
|
bodyText: await page.locator('body').innerText().catch(() => '') };
|
|
fs.writeFileSync(path.join(outputDirectory, 'failure.json'), `${JSON.stringify(failed, null, 2)}\n`);
|
|
console.error(failed.bodyText.slice(-2_500));
|
|
throw error;
|
|
} finally {
|
|
await browser.close();
|
|
await new Promise((resolve) => runtime.server.close(resolve));
|
|
const target = fs.realpathSync(dataDirectory);
|
|
if (path.dirname(target) !== fs.realpathSync(os.tmpdir())
|
|
|| !path.basename(target).startsWith('healer-man-native-routes-')) {
|
|
throw new Error('Unsafe temporary playtest database cleanup path.');
|
|
}
|
|
fs.rmSync(target, { recursive: true, force: true });
|
|
}
|
|
})().catch((error) => { console.error(error); process.exitCode = 1; });
|