Files
healer-man/scripts/playtest-campaign-boss-pass.cjs
T
2026-10-04 13:13:35 -04:00

1729 lines
93 KiB
JavaScript

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 boss-route QA. Navigation waypoints are tested through
// ordinary WASD movement; player/combat state is read-only in the harness.
const artifactRoot = path.resolve('playtest-artifacts/campaign-boss-pass');
const stamp = new Date().toISOString().replace(/[-:.]/g, '').replace('T', '-').replace('Z', '');
const baseline = process.argv.includes('--baseline');
const outputDirectory = path.join(artifactRoot, `${baseline ? 'exploration-current-dist' : 'final-build'}-${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 cameraDiagnostic = process.argv.includes('--camera-diagnostic');
const campaignBossRoute = process.argv.includes('--campaign-boss-route');
const maxCampaignSecondsOption = process.argv.find((value) => value.startsWith('--max-campaign-seconds='));
const maxCampaignDurationMs = Math.max(60, Number(maxCampaignSecondsOption?.split('=')[1] ?? 720)) * 1_000;
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-native-routes-'));
const navigationGuidancePath = path.resolve('playtest-artifacts/campaign-boss-pass/navigation-guidance.jsonl');
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: 'wailing-caverns', title: 'Wailing Caverns', category: 'WoW' },
];
const routesToRun = routeSelection ? routes.filter((route) => route.id === routeSelection) : routes;
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, dungeonRun,
getMobPosition, mobPositionExports, getPartyPosition, partyPositionExports }) => {
const gameState = game.getState();
const combatState = combat.getState();
const dungeonRunProgress = dungeonRun?.getState?.()?.progress ?? null;
const canvas = document.querySelector('canvas');
const fiber = canvas?.__r3f;
const fiberStore = fiber?.root?.store ?? fiber?.store;
const exportedRootMaps = window.nativeRouteRootMaps ?? [];
const mappedRoot = exportedRootMaps.flatMap(({ name, value }) => Array.from(value.entries(), ([key, root]) => ({
name, keyTag: key?.tagName ?? key?.nodeName ?? null, root,
}))).find(({ root }) => root?.store?.getState?.()?.camera);
const rootState = fiberStore?.getState?.() ?? mappedRoot?.root?.store?.getState?.() ?? null;
const renderedCamera = rootState?.camera ?? null;
const cameraPosition = renderedCamera?.position;
const cameraQuaternion = renderedCamera?.quaternion;
const cameraRotation = renderedCamera?.rotation;
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,
renderedCamera: renderedCamera ? {
position: [cameraPosition.x, cameraPosition.y, cameraPosition.z],
quaternion: [cameraQuaternion.x, cameraQuaternion.y, cameraQuaternion.z, cameraQuaternion.w],
rotation: [cameraRotation.x, cameraRotation.y, cameraRotation.z],
fov: renderedCamera.fov ?? null,
} : null,
cameraProbe: {
canvasFound: Boolean(canvas),
fiberFound: Boolean(fiber),
fiberKeys: fiber ? Object.keys(fiber).slice(0, 12) : [],
storeFound: Boolean(fiberStore || mappedRoot),
rootMap: mappedRoot ? { exportName: mappedRoot.name, keyTag: mappedRoot.keyTag } : null,
availableRootMapExports: exportedRootMaps.map((entry) => entry.name),
canvasKeys: canvas ? Reflect.ownKeys(canvas).map(String).slice(0, 12) : [],
pointerLockElement: document.pointerLockElement?.tagName ?? null,
},
areaName: gameState.areaName,
distanceTravelled: gameState.distanceTravelled,
hasMoved: gameState.hasMoved,
overlay: gameState.overlay,
dungeonIntroActive: gameState.dungeonIntroActive,
},
dungeonRun: dungeonRunProgress ? {
dungeonId: dungeonRunProgress.dungeonId,
difficultyId: dungeonRunProgress.difficultyId,
startedAt: dungeonRunProgress.startedAt,
finishedAt: dungeonRunProgress.finishedAt,
durationMs: dungeonRunProgress.durationMs,
killedBossIds: [...(dungeonRunProgress.killedBossIds ?? [])],
timed: dungeonRunProgress.timed,
bossDifficulty: dungeonRunProgress.bossDifficulty,
} : null,
dungeonRunStoreFound: Boolean(dungeonRun),
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,
spellId: mob.activeCast.spellId ?? null, startedAt: mob.activeCast.startedAt,
completesAt: mob.activeCast.completesAt, interruptible: mob.activeCast.interruptible } : 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 };
}
function readNavigationGuidance(dungeonId) {
if (!fs.existsSync(navigationGuidancePath)) throw new Error(`Missing navigation guide: ${navigationGuidancePath}`);
const rows = fs.readFileSync(navigationGuidancePath, 'utf8').trim().split(/\r?\n/)
.filter(Boolean).map((line) => JSON.parse(line));
const guide = rows.find((row) => row.dungeonId === dungeonId);
if (!guide || guide.status !== 'navigation-guidance-only; live UI traversal required'
|| !Array.isArray(guide.waypoints) || guide.waypoints.length < 2) {
throw new Error(`${dungeonId}: source navigation guide is absent or malformed.`);
}
return guide;
}
function navigationCheckpoints(waypoints) {
let accumulatedDistance = 0;
const checkpoints = [];
for (let index = 1; index < waypoints.length; index += 1) {
const previous = waypoints[index - 1];
const position = waypoints[index];
const distance = distance3(previous, position);
if (distance < 0.05) continue;
accumulatedDistance += distance;
checkpoints.push({ waypointIndex: index, position,
accumulatedDistance: Number(accumulatedDistance.toFixed(2)),
reason: index === waypoints.length - 1 ? 'objective' : 'source-navigation-vertex' });
}
return checkpoints;
}
function ordinaryMovementSpeedEstimate(movement) {
const speeds = movement.slice(-16).filter((step) => !step.blocked && step.durationMs >= 60
&& step.planarDisplacement >= 0.08).map((step) => step.planarDisplacement / (step.durationMs / 1_000))
.filter((speed) => Number.isFinite(speed) && speed >= 1 && speed <= 12).sort((left, right) => left - right);
if (!speeds.length) return 4.2;
return Math.min(8, Math.max(2, speeds[Math.floor(speeds.length / 2)]));
}
async function captureNormalCameraInputProbe(page, directory, prefix) {
const screenshots = {
before: `${prefix}-before-right-drag.png`,
after: `${prefix}-after-right-drag.png`,
afterUnheld: `${prefix}-after-unheld-move.png`,
};
const before = await readLiveState(page);
await page.screenshot({ path: path.join(directory, screenshots.before) });
await page.mouse.move(720, 450);
await page.mouse.down({ button: 'right' });
try {
await page.mouse.move(900, 450, { steps: 12 });
await page.waitForTimeout(250);
} finally {
await page.mouse.up({ button: 'right' });
}
await page.waitForTimeout(350);
const afterRightDrag = await readLiveState(page);
await page.screenshot({ path: path.join(directory, screenshots.after) });
await page.mouse.move(1_080, 450, { steps: 12 });
await page.waitForTimeout(350);
const afterUnheldMove = await readLiveState(page);
await page.screenshot({ path: path.join(directory, screenshots.afterUnheld) });
const yawDelta = Math.atan2(Math.sin(afterRightDrag.game.cameraYaw - before.game.cameraYaw),
Math.cos(afterRightDrag.game.cameraYaw - before.game.cameraYaw));
const unheldYawDelta = Math.atan2(Math.sin(afterUnheldMove.game.cameraYaw - afterRightDrag.game.cameraYaw),
Math.cos(afterUnheldMove.game.cameraYaw - afterRightDrag.game.cameraYaw));
return {
before: { playerPosition: before.player.position, cameraYaw: before.game.cameraYaw,
renderedCamera: before.game.renderedCamera, probe: before.game.cameraProbe },
afterRightDrag: { playerPosition: afterRightDrag.player.position,
cameraYaw: afterRightDrag.game.cameraYaw, renderedCamera: afterRightDrag.game.renderedCamera,
probe: afterRightDrag.game.cameraProbe },
afterUnheldMove: { playerPosition: afterUnheldMove.player.position,
cameraYaw: afterUnheldMove.game.cameraYaw, renderedCamera: afterUnheldMove.game.renderedCamera,
probe: afterUnheldMove.game.cameraProbe },
screenshots,
yawDelta,
unheldYawDelta,
playerDisplacementDuringDrag: distance3(before.player.position, afterRightDrag.player.position),
playerDisplacementAfterRelease: distance3(afterRightDrag.player.position, afterUnheldMove.player.position),
pointerLockStayedNull: before.game.cameraProbe.pointerLockElement === null
&& afterRightDrag.game.cameraProbe.pointerLockElement === null
&& afterUnheldMove.game.cameraProbe.pointerLockElement === null,
unheldYawStable: Math.abs(unheldYawDelta) < 0.02,
};
}
async function assignVowThroughAbilityControls(page) {
const abilities = await readLiveState(page, true);
const vow = abilities.abilities.find((ability) => ability.dbcSpellId === 490275);
if (!vow) return { learned: false, reason: 'selected identity does not expose DBC 490275 in its learned ability list' };
await page.keyboard.press('Escape');
await page.waitForTimeout(350);
await page.getByRole('button', { name: 'Ability controls' }).click();
await page.locator('.ability-bindings__diagram').waitFor({ timeout: 15_000 });
await page.locator('.ability-bindings__layers button').nth(1).click();
const vowSlot = page.locator('button[aria-label*="Shift+5:"]');
await vowSlot.click();
const abilityChoice = page.getByRole('button', { name: new RegExp(`^${escapeRegex(vow.name)}(?:,|$)`) });
await abilityChoice.click();
await page.screenshot({ path: path.join(outputDirectory, 'wailing-caverns-vow-binding-ui.png') });
const after = await readLiveState(page, true);
const binding = Object.entries(after.actionBindings).find(([, id]) => id === vow.id)?.[0] ?? null;
await page.getByRole('button', { name: 'Close button assignments' }).click();
await page.getByRole('button', { name: 'Resume game' }).click();
return {
learned: true, id: vow.id, name: vow.name, dbcSpellId: vow.dbcSpellId,
unlockLevel: vow.unlockLevel, playerLevel: after.player.level, boundLayerSlot: binding,
input: binding === 'secondary:r1' ? 'Shift+Digit5' : null,
menuAssignmentObserved: binding === 'secondary:r1',
};
}
async function runCampaignBossRoute(page, route, entryState, profile) {
const guidance = readNavigationGuidance(route.id);
const vowSetup = route.id === 'wailing-caverns' ? await assignVowThroughAbilityControls(page) : null;
if (route.id === 'wailing-caverns') entryState = await readLiveState(page, true);
const plan = actionPlanFromSnapshot(entryState);
const result = {
dungeonId: route.id, title: route.title,
coverage: `normal campaign navigation attempt from entrance to ${guidance.objective}; navigation graph used only as a WASD guide`,
enteredThrough: 'normal Dungeons picker and healer role selection',
bossTesting: false, profile,
objective: { name: guidance.objective, position: guidance.objectivePosition },
navigationGuidance: {
status: guidance.status, expectedNavigationDistance: guidance.expectedNavigationDistance,
waypointCount: guidance.waypointCount, startSnapDistance: guidance.startSnapDistance,
objectiveSnapDistance: guidance.objectiveSnapDistance,
checkpoints: navigationCheckpoints(guidance.waypoints),
livePositionMutationUsed: false,
},
playerAtEntry: entryState.player, partyAtEntry: entryState.party,
dungeonRunStoreFoundAtEntry: entryState.dungeonRunStoreFound,
dungeonRunProgressObserved: [],
dungeonEntry: entryState.game, actionPlan: plan,
vowSetup,
startingPosition: entryState.player.position,
movement: [], targeting: [], actions: [], samples: [], observedCombatEvents: [],
packEncounters: [],
screenshots: {
entrance: `${route.id}-entrance.png`, route25: `${route.id}-boss-route-25.png`,
route50: `${route.id}-boss-route-50.png`, route75: `${route.id}-boss-route-75.png`,
route100: `${route.id}-boss-route-100.png`,
boss: `${route.id}-boss-arrival.png`, endpoint: `${route.id}-boss-route-end.png`,
cameraBefore: `${route.id}-camera-before-right-drag.png`,
cameraAfter: `${route.id}-camera-after-right-drag.png`,
cameraAfterUnheld: `${route.id}-camera-after-unheld-move.png`,
},
segmentStartedAt: Date.now(),
};
if (!plan.damage) throw new Error(`${route.id}: no normal bound damage ability for campaign route.`);
if (route.id === 'wailing-caverns' && !result.vowSetup?.menuAssignmentObserved) {
result.vowSetup?.learned || console.warn('Vow of Silence was not observed in the selected identity action catalog.');
}
const initialEventKeys = new Set(entryState.events.map((event) => [event.occurredAt, event.sourceActorId,
event.targetActorId, event.abilityId, event.kind, event.effectiveAmount].join('|')));
const seenEventKeys = initialEventKeys;
const observedDeadMobs = new Map();
let lastDungeonRunProgressKey = null;
const collect = (snapshot) => {
if (snapshot.dungeonRun) {
const progressKey = JSON.stringify(snapshot.dungeonRun);
if (progressKey !== lastDungeonRunProgressKey) {
lastDungeonRunProgressKey = progressKey;
result.dungeonRunProgressObserved.push(snapshot.dungeonRun);
}
}
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 deadline = Date.now() + maxCampaignDurationMs;
const checkpoints = result.navigationGuidance.checkpoints;
const checkpointTolerance = 0.65;
result.initialEnemyCount = liveMobs(entryState).length;
const firstTarget = await selectHostileWithTab(page, result, 8);
if (firstTarget) {
await page.keyboard.press('F1');
result.partyCommand = { at: Date.now(), key: 'F1', command: 'Attack', selectedTarget: firstTarget.target.name };
await page.waitForTimeout(300);
await page.keyboard.press('F2');
result.partyCommandAfterOpeningTarget = { at: Date.now(), key: 'F2', command: 'Defend', reason: 'keep opening pack near healer' };
} else result.partyCommand = { at: Date.now(), key: 'F1', command: 'Attack', selectedTarget: null };
let current = await readLiveState(page);
let routeBlocked = null;
let movedSteps = 0;
let nextScreenshotMark = 25;
let lastSampleAt = 0;
for (let checkpointIndex = 0; checkpointIndex < checkpoints.length && Date.now() < deadline; checkpointIndex += 1) {
const checkpoint = checkpoints[checkpointIndex];
let blockedStreak = 0;
let stepAttempts = 0;
while (Date.now() < deadline && distance3(current.player.position, checkpoint.position) > checkpointTolerance) {
if (current.player.health <= 0) { routeBlocked = { kind: 'player-death', checkpointIndex, position: current.player.position }; break; }
const beforePosition = current.player.position;
const beforeDistance = distance3(beforePosition, checkpoint.position);
const planarRemaining = distancePlanar(beforePosition, checkpoint.position);
const speedEstimate = ordinaryMovementSpeedEstimate(result.movement);
const durationMs = Math.max(45, Math.min(350, Math.floor(planarRemaining * 0.58 / speedEstimate * 1_000)));
const moved = await walkToward(page, result, checkpoint.position, durationMs);
if (!moved) {
routeBlocked = { kind: 'no-directional-input', checkpointIndex, checkpoint, position: current.player.position };
break;
}
current = moved.snapshot;
collect(current);
movedSteps += 1;
stepAttempts += 1;
const verticalDelta = current.player.position[1] - beforePosition[1];
if (verticalDelta < -3) {
routeBlocked = { kind: 'unexpected-live-vertical-drop', checkpointIndex, checkpoint,
from: beforePosition, to: current.player.position, verticalDelta,
note: 'Stopped after a large downward displacement from ordinary movement; no route point was skipped.' };
break;
}
const engagedNearPlayer = current.mobs.some((mob) => !mob.dead && !mob.despawned && mob.engaged
&& hasFinitePosition(mob.position) && distance3(current.player.position, mob.position) <= 48);
const hurtPartyNear = current.party.some((member) => member.health > 0
&& member.health < member.maxHealth * 0.78);
if (engagedNearPlayer || hurtPartyNear || current.player.health < current.player.maxHealth * 0.68) {
current = await stabilizeCampaignPack(page, result, current, profile, plan, collect, deadline,
`route checkpoint ${checkpointIndex} live engaged pack / triage`);
if (current.game.overlay === 'dungeon-results') {
routeBlocked = { kind: 'dungeon-results-overlay-open', checkpointIndex,
note: 'Native result overlay ended route input; result evidence is read from the visible UI.' };
break;
}
if (current.player.health <= 0) {
routeBlocked = { kind: 'player-death-during-pack-stabilization', checkpointIndex,
position: current.player.position, pack: result.packEncounters.at(-1) };
break;
}
}
const clippedCameraReference = [157.31972, -74.52294, 129.89132];
if (cameraDiagnostic && route.id === 'wailing-caverns' && !result.cameraDiagnosticAtClippedEndpoint
&& distance3(current.player.position, clippedCameraReference) <= 5.5) {
result.cameraDiagnosticAtClippedEndpoint = {
referencePosition: clippedCameraReference,
actualPositionBeforeProbe: current.player.position,
referenceDistance: distance3(current.player.position, clippedCameraReference),
inputProbe: await captureNormalCameraInputProbe(page, outputDirectory,
'wailing-caverns-original-clipped-endpoint'),
};
current = await readLiveState(page);
collect(current);
}
const afterDistance = distance3(current.player.position, checkpoint.position);
blockedStreak = moved.step.planarDisplacement < 0.08 || afterDistance >= beforeDistance - 0.05
? blockedStreak + 1 : 0;
if (blockedStreak >= 5 || stepAttempts >= 24) {
const failure = { kind: blockedStreak >= 5 ? 'movement-blocked' : 'checkpoint-timeout',
checkpointIndex, checkpoint, stepAttempts, playerPosition: current.player.position,
distanceToCheckpoint: afterDistance, recentMovement: result.movement.slice(-5) };
failure.response = 'stopped at the unverified source point; no geometry corner, elevation change, or gap was skipped';
routeBlocked = failure;
break;
}
if (lastSampleAt === 0 || current.now - lastSampleAt > 1_500) {
result.samples.push({ at: current.now, checkpointIndex, player: current.player,
party: current.party, mobs: current.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 = current.now;
}
const progressPercent = Math.floor((checkpointIndex + 1) / checkpoints.length * 100);
while (progressPercent >= nextScreenshotMark && nextScreenshotMark <= 100) {
const imagePath = path.join(outputDirectory, result.screenshots[`route${nextScreenshotMark}`]);
await page.screenshot({ path: imagePath });
result.navigationGuidance.checkpoints[checkpointIndex].screenshotMark = nextScreenshotMark;
result.navigationGuidance.checkpoints[checkpointIndex].screenshotPosition = current.player.position;
result.navigationGuidance.checkpoints[checkpointIndex].screenshotTravelled = current.game.distanceTravelled;
nextScreenshotMark += 25;
}
}
result.navigationGuidance.checkpoints[checkpointIndex].observedPosition = current.player.position;
result.navigationGuidance.checkpoints[checkpointIndex].verified = !routeBlocked
&& distance3(current.player.position, checkpoint.position) <= checkpointTolerance;
if (routeBlocked) break;
}
current = await readLiveState(page);
collect(current);
const nativeResultText = current.game.overlay === 'dungeon-results'
? await page.locator('.dungeon-results-backdrop').innerText().catch(() => '') : '';
result.dungeonResult = {
overlay: current.game.overlay,
visibleText: nativeResultText,
fullClearVisible: /FULL DUNGEON CLEAR/i.test(nativeResultText),
objectiveDefeatedVisible: new RegExp(escapeRegex(guidance.objective), 'i').test(nativeResultText)
&& /Defeated/i.test(nativeResultText),
};
if (result.dungeonResult.fullClearVisible && !result.bossFight?.startedAt) {
result.bossFight = { outcome: 'native-full-dungeon-clear-observed-before-harness-boss-handler', attempted: false };
}
const bossDistance = distance3(current.player.position, guidance.objectivePosition);
let liveBoss = current.mobs.find((mob) => mob.name === guidance.objective && mob.boss && !mob.dead) ?? null;
const approachGuardPacks = [];
if (current.game.overlay !== 'dungeon-results' && bossDistance <= 18 && !liveBoss) {
for (let packIndex = 0; packIndex < 5 && Date.now() < deadline && !liveBoss; packIndex += 1) {
current = await readLiveState(page);
collect(current);
liveBoss = current.mobs.find((mob) => mob.name === guidance.objective && mob.boss && !mob.dead) ?? null;
if (liveBoss) break;
const localMobs = liveMobs(current).filter((mob) => !mob.boss && hasFinitePosition(mob.position)
&& distance3(mob.position, guidance.objectivePosition) <= 48);
if (!localMobs.length) break;
const targetId = new Set(localMobs.map((mob) => mob.id));
let selectedLocal = current.mobs.find((mob) => mob.id === current.player.selectedTargetId && targetId.has(mob.id));
for (let attempt = 0; !selectedLocal && attempt < 16; attempt += 1) {
await page.keyboard.press('Tab');
await page.waitForTimeout(60);
current = await readLiveState(page);
collect(current);
selectedLocal = current.mobs.find((mob) => mob.id === current.player.selectedTargetId && targetId.has(mob.id)) ?? null;
}
if (!selectedLocal) {
approachGuardPacks.push({ index: packIndex, outcome: 'nearby-mobs-not-cycle-targetable',
nearbyMobs: localMobs.map((mob) => ({ id: mob.id, name: mob.name, distance: distance3(current.player.position, mob.position) })) });
break;
}
await page.keyboard.press('F1');
await page.waitForTimeout(300);
await page.keyboard.press('F2');
current = await readLiveState(page);
collect(current);
const stabilized = await stabilizeCampaignPack(page, result, current, profile, plan, collect, deadline,
`Lady Anacondra lair approach pack ${packIndex + 1}`);
current = stabilized;
const pack = result.packEncounters.at(-1);
approachGuardPacks.push({ index: packIndex, target: selectedLocal.name, outcome: pack.outcome,
initialEngagedMobs: pack.initialEngagedMobs.map((mob) => mob.name),
heals: pack.heals.length, damageCasts: pack.damage.length });
await page.waitForTimeout(350);
current = await readLiveState(page);
collect(current);
liveBoss = current.mobs.find((mob) => mob.name === guidance.objective && mob.boss && !mob.dead) ?? null;
}
}
const playerToBoss = liveBoss ? distance3(current.player.position, liveBoss.position) : bossDistance;
const playerToObjectiveAfterApproach = distance3(current.player.position, guidance.objectivePosition);
result.navigationResult = {
expectedDistance: guidance.expectedNavigationDistance,
observedDistanceTravelled: current.game.distanceTravelled - entryState.game.distanceTravelled,
inputStepDistance: result.movement.reduce((sum, step) => sum + step.planarDisplacement, 0),
playerPosition: current.player.position,
bossPosition: guidance.objectivePosition,
distanceToBoss: playerToObjectiveAfterApproach,
liveBossLoaded: Boolean(liveBoss),
bossReached: playerToObjectiveAfterApproach <= 10 || Boolean(liveBoss && distance3(current.player.position, liveBoss.position) <= 10),
movedSteps, checkpointsVerified: result.navigationGuidance.checkpoints.filter((item) => item.verified).length,
checkpointsTotal: checkpoints.length,
blocked: routeBlocked,
approachGuardPacks,
liveBossLoadedAfterApproachClear: Boolean(liveBoss),
playerToLiveBossDistance: Number.isFinite(playerToBoss) ? playerToBoss : null,
};
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.boss) });
if (result.navigationResult.bossReached && liveBoss) {
const selectionAttempts = [];
let bossTargetState = current;
for (let attempt = 0; attempt < 40 && Date.now() < deadline; attempt += 1) {
if (bossTargetState.player.selectedTargetId === liveBoss.id) break;
await page.keyboard.press('Tab');
await page.waitForTimeout(60);
bossTargetState = await readLiveState(page);
const selected = bossTargetState.mobs.find((mob) => mob.id === bossTargetState.player.selectedTargetId);
selectionAttempts.push({ at: bossTargetState.now, selectedId: bossTargetState.player.selectedTargetId,
selectedName: selected?.name ?? null, selectedBoss: Boolean(selected?.boss) });
}
result.bossTargetSelection = { bossId: liveBoss.id, bossName: liveBoss.name,
attempts: selectionAttempts, selected: bossTargetState.player.selectedTargetId === liveBoss.id };
if (result.bossTargetSelection.selected) {
await page.keyboard.press('F1');
result.bossFight = { startedAt: Date.now(), command: 'F1 Attack', targetId: liveBoss.id,
playerDamageCasts: [], vowAttempts: [], samples: [], outcome: 'in-progress' };
let lastDamage = 0;
let lastHeal = 0;
const fightDeadline = Math.min(deadline, Date.now() + 150_000);
while (Date.now() < fightDeadline) {
current = await readLiveState(page);
collect(current);
const boss = current.mobs.find((mob) => mob.id === liveBoss.id);
if (!boss || boss.dead || boss.health <= 0) {
result.bossFight.outcome = boss?.dead || boss?.health <= 0 ? 'boss-dead' : 'boss-despawned';
result.bossFight.bossAtEnd = boss ?? null;
break;
}
if (current.player.health <= 0) { result.bossFight.outcome = 'player-dead'; break; }
if (result.bossFight.samples.length === 0 || current.now - result.bossFight.samples.at(-1).at >= 500) {
result.bossFight.samples.push({ at: current.now, bossHealth: boss.health,
bossCast: boss.activeCast, playerHealth: current.player.health,
partyHealth: current.party.map((member) => ({ name: member.name, health: member.health,
maxHealth: member.maxHealth, position: member.position })) });
}
if (current.player.selectedTargetId !== liveBoss.id) {
await page.keyboard.press('Tab');
await page.waitForTimeout(40);
current = await readLiveState(page);
}
const vowBinding = result.vowSetup?.menuAssignmentObserved ? 'Shift+Digit5' : null;
const liveCast = current.mobs.find((mob) => mob.id === liveBoss.id)?.activeCast;
if (vowBinding && liveCast?.attackId === 'spell-9532' && liveCast.completesAt > current.now
&& current.player.selectedTargetId === liveBoss.id) {
const attempt = { observedAt: current.now, castStartedAt: liveCast.startedAt,
scheduledCompletionAt: liveCast.completesAt, remainingMs: liveCast.completesAt - current.now,
input: vowBinding, selectedTargetId: current.player.selectedTargetId,
castBefore: liveCast, playerEventsBefore: current.events.filter((event) => event.abilityId === result.vowSetup.id).slice(-3) };
await page.keyboard.press(vowBinding);
await page.waitForTimeout(80);
const afterVow = await readLiveState(page);
const afterBoss = afterVow.mobs.find((mob) => mob.id === liveBoss.id);
const vowCastEvents = afterVow.events.filter((event) => event.sourceActorId === '__player__'
&& event.abilityId === result.vowSetup.id && event.occurredAt >= attempt.observedAt - 10);
attempt.inputAt = Date.now();
attempt.castAfter = afterBoss?.activeCast ?? null;
attempt.afterReadAt = afterVow.now;
attempt.cancelledBeforeScheduledRelease = Boolean(!afterBoss?.activeCast
&& afterVow.now < liveCast.completesAt);
attempt.playerVowEventsAfter = vowCastEvents;
result.bossFight.vowAttempts.push(attempt);
current = afterVow;
}
const wounded = current.party.filter((member) => member.health > 0
&& member.health < member.maxHealth * 0.58 && hasFinitePosition(member.position))
.sort((a, b) => a.health / a.maxHealth - b.health / b.maxHealth)[0];
if (wounded && plan.healing && current.now - lastHeal > 1_200
&& distance3(current.player.position, wounded.position) <= Number(plan.healing.range?.max ?? 25) - 1) {
await selectPartyMember(page, wounded);
const cast = await performCast(page, result, plan.healing, 'healing', wounded.id,
`live Lady Anacondra fight support: ${wounded.name} below 58%`);
result.bossFight.playerDamageCasts.push({ ...cast.cast, role: 'party-heal' });
lastHeal = Date.now();
if (cast.cast.actionResolved) result.bossFight.heals ??= 0, result.bossFight.heals += 1;
await page.keyboard.press('Tab');
continue;
}
if (current.player.selectedTargetId === liveBoss.id && current.now - lastDamage >= 1_500
&& distance3(current.player.position, boss.position) <= Number(plan.damage.range?.max ?? 20) - 1) {
const cast = await performCast(page, result, plan.damage, 'damage', liveBoss.id,
'normal keyboard damage against current live campaign boss');
result.bossFight.playerDamageCasts.push(cast.cast);
lastDamage = Date.now();
if (cast.cast.actionResolved) result.effectivePlayerDamageCasts = (result.effectivePlayerDamageCasts ?? 0) + 1;
continue;
}
if (Date.now() - result.bossFight.startedAt > 20_000 && result.bossFight.vowAttempts.length === 0
&& result.vowSetup?.learned) {
const cast = current.mobs.find((mob) => mob.id === liveBoss.id)?.activeCast;
result.bossFight.castOpportunityStatus = cast?.attackId === 'spell-9532'
? 'live-spell-9532-cast-observed-but-not-selected-or-out-of-time'
: 'no-live-spell-9532-cast-observed-yet';
}
await page.waitForTimeout(75);
}
result.bossFight.endedAt = Date.now();
result.bossFight.durationMs = result.bossFight.endedAt - result.bossFight.startedAt;
if (result.bossFight.outcome === 'in-progress') result.bossFight.outcome = 'fight-time-budget-ended';
current = await readLiveState(page);
collect(current);
result.bossFight.bossAtFinalRead = current.mobs.find((mob) => mob.id === liveBoss.id) ?? null;
result.bossFight.dungeonResultOverlay = current.game.overlay;
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.endpoint) });
}
} else if (!result.dungeonResult.fullClearVisible) {
result.bossFight = { outcome: 'boss-not-reached-or-not-loaded', attempted: false };
}
const finalNativeResultText = current.game.overlay === 'dungeon-results'
? await page.locator('.dungeon-results-backdrop').innerText().catch(() => '') : '';
result.dungeonResult = {
overlay: current.game.overlay,
visibleText: finalNativeResultText,
fullClearVisible: /FULL DUNGEON CLEAR/i.test(finalNativeResultText),
objectiveDefeatedVisible: new RegExp(escapeRegex(guidance.objective), 'i').test(finalNativeResultText)
&& /Defeated/i.test(finalNativeResultText),
};
if (result.dungeonResult.fullClearVisible && !result.bossFight?.startedAt) {
result.bossFight = { outcome: 'native-full-dungeon-clear-observed-before-harness-boss-handler', attempted: false };
}
if (cameraDiagnostic && current.game.overlay !== 'dungeon-results') {
const beforeDrag = await readLiveState(page);
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.cameraBefore) });
await page.mouse.move(720, 450);
await page.mouse.down({ button: 'right' });
try {
await page.mouse.move(900, 450, { steps: 12 });
await page.waitForTimeout(250);
} finally {
await page.mouse.up({ button: 'right' });
}
await page.waitForTimeout(350);
const afterDrag = await readLiveState(page);
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.cameraAfter) });
await page.mouse.move(1_080, 450, { steps: 12 });
await page.waitForTimeout(350);
const afterUnheldMove = await readLiveState(page);
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.cameraAfterUnheld) });
const yawDelta = Math.atan2(Math.sin(afterDrag.game.cameraYaw - beforeDrag.game.cameraYaw),
Math.cos(afterDrag.game.cameraYaw - beforeDrag.game.cameraYaw));
const unheldYawDelta = Math.atan2(Math.sin(afterUnheldMove.game.cameraYaw - afterDrag.game.cameraYaw),
Math.cos(afterUnheldMove.game.cameraYaw - afterDrag.game.cameraYaw));
result.cameraDiagnostic = {
before: { playerPosition: beforeDrag.player.position, cameraYaw: beforeDrag.game.cameraYaw,
renderedCamera: beforeDrag.game.renderedCamera, probe: beforeDrag.game.cameraProbe },
afterRightDrag: { playerPosition: afterDrag.player.position, cameraYaw: afterDrag.game.cameraYaw,
renderedCamera: afterDrag.game.renderedCamera, probe: afterDrag.game.cameraProbe },
afterUnheldMove: { playerPosition: afterUnheldMove.player.position,
cameraYaw: afterUnheldMove.game.cameraYaw, renderedCamera: afterUnheldMove.game.renderedCamera,
probe: afterUnheldMove.game.cameraProbe },
yawDelta,
unheldYawDelta,
playerDisplacementDuringDrag: distance3(beforeDrag.player.position, afterDrag.player.position),
playerDisplacementAfterRelease: distance3(afterDrag.player.position, afterUnheldMove.player.position),
pointerLockStayedNull: beforeDrag.game.cameraProbe.pointerLockElement === null
&& afterDrag.game.cameraProbe.pointerLockElement === null
&& afterUnheldMove.game.cameraProbe.pointerLockElement === null,
unheldYawStable: Math.abs(unheldYawDelta) < 0.02,
};
} else if (cameraDiagnostic) {
result.cameraDiagnostic = { skipped: 'dungeon-results overlay is open; camera input is unavailable' };
}
result.endingPosition = current.player.position;
result.endingDistanceTravelled = current.game.distanceTravelled;
result.partyAtEndpoint = current.party;
result.playerAtEndpoint = current.player;
result.sceneMobsAtEndpoint = current.mobs;
result.observedDeadMobs = [...observedDeadMobs.values()];
result.partyDeaths = current.party.filter((member) => member.health <= 0).map((member) => member.name);
result.effectiveNativeHealing = result.observedCombatEvents.filter((event) => event.kind === 'healing'
&& event.sourceActorId === '__player__' && event.effectiveAmount > 0).reduce((sum, event) => sum + event.effectiveAmount, 0);
const objectiveMob = current.mobs.find((mob) => mob.name === guidance.objective)
?? entryState.mobs.find((mob) => mob.name === guidance.objective)
?? result.samples.flatMap((sample) => sample.mobs).find((mob) => mob.name === guidance.objective)
?? null;
const objectiveId = objectiveMob?.id ?? null;
const objectiveEvents = objectiveId ? result.observedCombatEvents.filter((event) =>
event.sourceActorId === objectiveId || event.targetActorId === objectiveId) : [];
const objectiveDamageEvents = objectiveEvents.filter((event) => event.kind === 'damage'
&& event.targetActorId === objectiveId && event.effectiveAmount > 0);
const objectiveDefeatedFeedback = objectiveId ? result.actions.find((action) =>
(action.intendedTargetId === objectiveId || action.selectedTargetIdBefore === objectiveId)
&& action.feedback?.kind === 'error' && /already defeated/i.test(action.feedback.message)) ?? null : null;
const objectiveRunCompletionObserved = objectiveId
&& result.dungeonRunProgressObserved.some((progress) => progress.killedBossIds?.includes(objectiveId));
result.objectiveCombatAudit = {
objectiveId,
dungeonRunStoreFoundAtEntry: result.dungeonRunStoreFoundAtEntry,
objectiveRunCompletionObserved: Boolean(objectiveRunCompletionObserved),
combatEventCount: objectiveEvents.length,
incomingDamageEventCount: objectiveDamageEvents.length,
playerIncomingDamageEventCount: objectiveDamageEvents.filter((event) => event.sourceActorId === '__player__').length,
alliedIncomingDamageEventCount: objectiveDamageEvents.filter((event) => event.sourceActorId !== '__player__').length,
effectiveDamageTotalSupplementaryOnly: objectiveDamageEvents.reduce((sum, event) => sum + event.effectiveAmount, 0),
lastEffectiveHit: objectiveDamageEvents.at(-1) ?? null,
alreadyDefeatedFeedback: objectiveDefeatedFeedback ? {
at: objectiveDefeatedFeedback.at,
targetHealthBefore: objectiveDefeatedFeedback.targetHealthBefore,
feedback: objectiveDefeatedFeedback.feedback,
} : null,
};
result.routeBossOutcome = objectiveRunCompletionObserved ? 'objective-completion-observed-in-dungeon-run-store'
: objectiveDefeatedFeedback && objectiveEvents.some((event) => event.sourceActorId === objectiveId)
&& objectiveDamageEvents.length > 0 ? 'boss-fight-and-already-defeated-feedback-observed-during-route'
: objectiveEvents.length > 0 ? 'boss-combat-observed; defeat-not-confirmed' : 'boss-combat-not-observed';
result.bossClearAttempted = Boolean(result.bossFight?.startedAt);
result.fullClearStatus = result.dungeonResult.fullClearVisible ? 'native-full-dungeon-clear-observed'
: objectiveRunCompletionObserved || result.routeBossOutcome === 'boss-fight-and-already-defeated-feedback-observed-during-route'
? 'first-boss-defeated; full dungeon clear still unverified'
: result.bossFight?.outcome === 'boss-dead' ? 'first-boss-defeated; full dungeon clear still unverified'
: result.bossFight?.outcome ?? 'navigation-ended-before-boss';
result.pageErrors = [];
result.consoleErrors = [];
fs.writeFileSync(path.join(outputDirectory, `${route.id}-campaign-boss-route.json`), `${JSON.stringify(result, null, 2)}\n`);
return result;
}
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 selectPlayerFrame(page, playerName) {
await page.getByRole('button', { name: new RegExp(`^Target ${escapeRegex(playerName)}, you$`) }).click();
}
function routePartySeparation(snapshot) {
return Math.max(0, ...snapshot.party.filter((member) => member.health > 0 && hasFinitePosition(member.position))
.map((member) => distance3(snapshot.player.position, member.position)));
}
async function stabilizeCampaignPack(page, result, snapshot, profile, plan, collect, deadline, label) {
const startedAt = Date.now();
const initialPack = snapshot.mobs.filter((mob) => !mob.dead && !mob.despawned && mob.engaged
&& hasFinitePosition(mob.position) && distance3(snapshot.player.position, mob.position) <= 48);
const record = {
label, startedAt,
initialEngagedMobs: initialPack.map((mob) => ({ id: mob.id, name: mob.name, health: mob.health, position: mob.position })),
movement: [], heals: [], damage: [], resurrectionActions: [],
outcome: 'in-progress',
};
result.packEncounters.push(record);
let current = snapshot;
let lastDamageAt = 0;
let clearedSince = null;
let blockStreak = 0;
const maxEncounterEnd = Math.min(deadline, Date.now() + 110_000);
await page.keyboard.press('F2');
record.initialPartyCommand = { at: Date.now(), key: 'F2', command: 'Defend', reason: 'hold engaged pack near healer' };
while (Date.now() < maxEncounterEnd) {
current = await readLiveState(page);
collect(current);
if (current.game.overlay === 'dungeon-results') {
record.outcome = 'dungeon-results-overlay-open';
record.dungeonResultOverlay = current.game.overlay;
break;
}
if (current.player.health <= 0) { record.outcome = 'player-dead'; break; }
const alivePack = current.mobs.filter((mob) => !mob.dead && !mob.despawned && mob.engaged
&& hasFinitePosition(mob.position) && distance3(current.player.position, mob.position) <= 58);
const woundedMember = current.party.filter((member) => member.health > 0
&& member.health < member.maxHealth * 0.88 && hasFinitePosition(member.position))
.sort((a, b) => a.health / a.maxHealth - b.health / b.maxHealth)[0] ?? null;
const selfNeedsHealing = current.player.health < current.player.maxHealth * 0.68;
const deadMembers = current.party.filter((member) => member.health <= 0);
// The visible resurrection panel invokes the game's normal party-recovery action;
// never write health or party positions from the harness.
if (!alivePack.length && deadMembers.length && record.resurrectionActions.length < 3) {
const rezButton = page.getByRole('button', { name: /Rez dead members/ });
if (await rezButton.isVisible().catch(() => false) && await rezButton.isEnabled().catch(() => false)) {
await rezButton.click();
record.resurrectionActions.push({ at: Date.now(), control: 'Rez dead members',
returnedNames: deadMembers.map((member) => member.name), selectedCheckpoint: 'normal UI default' });
await page.waitForTimeout(500);
current = await readLiveState(page);
collect(current);
continue;
}
}
if ((woundedMember || selfNeedsHealing) && plan.healing && record.heals.length < 16) {
const isSelf = selfNeedsHealing && (!woundedMember || current.player.health / current.player.maxHealth
< woundedMember.health / woundedMember.maxHealth);
const target = isSelf ? null : woundedMember;
const targetPosition = isSelf ? current.player.position : target.position;
const maxRange = Number(plan.healing.range?.max ?? 25) - 1.5;
let healDistance = distance3(current.player.position, targetPosition);
for (let approach = 0; healDistance > maxRange && approach < 6 && Date.now() < maxEncounterEnd; approach += 1) {
const moved = await walkToward(page, result, targetPosition, 500);
if (!moved) break;
record.movement.push(moved.step);
current = moved.snapshot;
collect(current);
healDistance = distance3(current.player.position, targetPosition);
if (moved.step.planarDisplacement < 0.08) break;
}
if (healDistance <= maxRange) {
try {
if (isSelf) await selectPlayerFrame(page, profile.name);
else await selectPartyMember(page, target);
} catch (error) {
current = await readLiveState(page);
collect(current);
if (current.game.overlay === 'dungeon-results') {
record.outcome = 'dungeon-results-overlay-open';
record.dungeonResultOverlay = current.game.overlay;
break;
}
throw error;
}
const heal = await performCast(page, result, plan.healing, 'healing', isSelf ? '__player__' : target.id,
isSelf ? 'campaign-route survival: player below 68%' : `campaign-route triage: ${target.name} below 88%`);
record.heals.push(heal.cast);
current = await readLiveState(page);
collect(current);
continue;
}
}
if (alivePack.length) {
clearedSince = null;
let target = alivePack.find((mob) => mob.id === current.player.selectedTargetId) ?? null;
for (let attempt = 0; !target && attempt < 12; attempt += 1) {
await page.keyboard.press('Tab');
await page.waitForTimeout(45);
current = await readLiveState(page);
collect(current);
target = alivePack.find((mob) => mob.id === current.player.selectedTargetId)
?? current.mobs.find((mob) => mob.id === current.player.selectedTargetId && alivePack.some((entry) => entry.id === mob.id));
}
if (!target) {
target = alivePack.sort((a, b) => distance3(current.player.position, a.position)
- distance3(current.player.position, b.position))[0];
}
if (current.player.selectedTargetId !== target.id) {
record.targetingFailures ??= [];
record.targetingFailures.push({ at: Date.now(), target: target.name,
selectedTargetId: current.player.selectedTargetId, targetDistance: distance3(current.player.position, target.position) });
}
await page.keyboard.press('F1');
record.attackCommands ??= [];
record.attackCommands.push({ at: Date.now(), key: 'F1', targetId: target.id, targetName: target.name });
await page.waitForTimeout(250);
await page.keyboard.press('F2');
record.attackCommands.push({ at: Date.now(), key: 'F2', targetId: target.id, command: 'Defend at healer' });
current = await readLiveState(page);
collect(current);
const freshTarget = current.mobs.find((mob) => mob.id === target.id && !mob.dead && !mob.despawned);
if (freshTarget && distance3(current.player.position, freshTarget.position) > Number(plan.damage.range?.max ?? 20) - 1) {
const move = await walkToward(page, result, freshTarget.position, 450);
if (move) { record.movement.push(move.step); current = move.snapshot; }
continue;
}
if (Date.now() - lastDamageAt > 1_050 && current.player.selectedTargetId === target.id) {
const attack = await performCast(page, result, plan.damage, 'damage', target.id,
'campaign route pack clear: normal keyboard attack on Tab-selected live mob');
record.damage.push(attack.cast);
lastDamageAt = Date.now();
current = await readLiveState(page);
collect(current);
} else await page.waitForTimeout(130);
continue;
}
if (woundedMember || selfNeedsHealing) continue;
if (routePartySeparation(current) > 22) {
record.partyConsolidation ??= [];
record.partyConsolidation.push({ at: current.now, maxSeparation: routePartySeparation(current),
action: 'wait for ordinary party-follow movement', position: current.player.position });
await page.waitForTimeout(700);
continue;
}
if (clearedSince === null) clearedSince = Date.now();
if (Date.now() - clearedSince >= 1_000) { record.outcome = 'pack-cleared-and-party-consolidated'; break; }
await page.waitForTimeout(150);
}
record.endedAt = Date.now();
record.durationMs = record.endedAt - startedAt;
if (record.outcome === 'in-progress') record.outcome = 'encounter-time-budget-ended';
record.playerAtEnd = current.player;
record.partyAtEnd = current.party;
record.liveEngagedMobsAtEnd = current.mobs.filter((mob) => !mob.dead && mob.engaged
&& distance3(current.player.position, mob.position) <= 58).map((mob) => ({ id: mob.id, name: mob.name,
health: mob.health, position: mob.position }));
record.partyMaxSeparationAtEnd = routePartySeparation(current);
return current;
}
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) {
if (campaignBossRoute) return runCampaignBossRoute(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),
};
if (cameraDiagnostic) {
result.cameraDiagnostic = {
before: {
playerPosition: final.player.position,
cameraYaw: final.game.cameraYaw,
camera: final.game.renderedCamera,
probe: final.game.cameraProbe,
},
screenshots: {
before: `${route.id}-camera-before-right-drag.png`,
after: `${route.id}-camera-after-right-drag.png`,
},
};
await page.screenshot({ path: path.join(outputDirectory, result.cameraDiagnostic.screenshots.before) });
await page.mouse.move(720, 450);
await page.mouse.down({ button: 'right' });
try {
await page.mouse.move(900, 450, { steps: 12 });
await page.waitForTimeout(250);
} finally {
await page.mouse.up({ button: 'right' });
}
await page.waitForTimeout(500);
const afterDrag = await readLiveState(page);
const yawDelta = Math.atan2(Math.sin(afterDrag.game.cameraYaw - final.game.cameraYaw),
Math.cos(afterDrag.game.cameraYaw - final.game.cameraYaw));
result.cameraDiagnostic.after = {
playerPosition: afterDrag.player.position,
cameraYaw: afterDrag.game.cameraYaw,
camera: afterDrag.game.renderedCamera,
probe: afterDrag.game.cameraProbe,
};
result.cameraDiagnostic.yawDelta = yawDelta;
result.cameraDiagnostic.playerDisplacement = distance3(final.player.position, afterDrag.player.position);
result.cameraDiagnostic.pointerLockStayedNull = afterDrag.game.cameraProbe.pointerLockElement === null;
await page.screenshot({ path: path.join(outputDirectory, result.cameraDiagnostic.screenshots.after) });
}
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');
window.nativeRouteRootMaps = namedExports.filter(([, value]) => value instanceof Map
&& [...value.values()].some((root) => root?.store?.getState?.()?.camera))
.map(([name, value]) => ({ name, value }));
// 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)),
dungeonRun: stores.find((store) => typeof store.getState()?.begin === 'function'
&& typeof store.getState()?.advance === 'function'
&& typeof store.getState()?.start === 'function'),
};
};
}, { 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 ? 'exploration-current-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 ? 'exploration-current-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; });