4093 lines
226 KiB
JavaScript
4093 lines
226 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');
|
|
const {
|
|
DEFAULT_TEST_DUNGEON_ID,
|
|
DEFAULT_TEST_DUNGEON_TITLE,
|
|
DEFAULT_TEST_DUNGEON_BOSS,
|
|
selectDefaultTestDungeon,
|
|
openSelectedDungeonRoles,
|
|
} = require('./playtest-defaults.cjs');
|
|
|
|
// 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 historicalWailing = process.argv.includes('--historical-wailing');
|
|
const cameraDiagnostic = process.argv.includes('--camera-diagnostic');
|
|
const campaignBossRoute = process.argv.includes('--campaign-boss-route');
|
|
const stockadeVowDiagnostic = process.argv.includes('--stockade-vow-diagnostic');
|
|
const maxCampaignSecondsOption = process.argv.find((value) => value.startsWith('--max-campaign-seconds='));
|
|
const requestedMaxCampaignSeconds = Number(maxCampaignSecondsOption?.split('=')[1]
|
|
?? (stockadeVowDiagnostic ? 900 : 1_800));
|
|
const maxCampaignDurationMs = Math.max(60, stockadeVowDiagnostic
|
|
? Math.min(900, requestedMaxCampaignSeconds) : requestedMaxCampaignSeconds) * 1_000;
|
|
const navigationGuidancePath = path.resolve('playtest-artifacts/campaign-boss-pass/navigation-guidance.jsonl');
|
|
const bundleModuleReport = JSON.parse(fs.readFileSync('dist/bundle-module-report.json', 'utf8'));
|
|
const dungeonRunStoreAsset = bundleModuleReport.chunks?.find((chunk) => chunk.modules?.some((modulePath) => {
|
|
const normalizedPath = modulePath.replace(/\\/g, '/').split('?')[0];
|
|
return normalizedPath === 'src/game/dungeonRunStore.ts'
|
|
|| normalizedPath.endsWith('/src/game/dungeonRunStore.ts');
|
|
}))?.file;
|
|
if (!dungeonRunStoreAsset) throw new Error('Build module report does not identify the dungeonRunStore owner chunk.');
|
|
const dungeonRunStoreAssetFile = path.basename(dungeonRunStoreAsset);
|
|
if (!fs.existsSync(path.join('dist/assets', dungeonRunStoreAssetFile))) {
|
|
throw new Error(`Build module report points to a missing dungeonRunStore chunk: ${dungeonRunStoreAssetFile}`);
|
|
}
|
|
const moduleFiles = [...new Set([
|
|
...fs.readdirSync('dist/assets')
|
|
.filter((file) => /^(?:index|GameRuntime|combatStore|partyStore|PartyFrames|romAbilityCatalog|dungeonSession|dungeonRuns)-.*\.js$/.test(file)),
|
|
dungeonRunStoreAssetFile,
|
|
])];
|
|
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' },
|
|
{ id: DEFAULT_TEST_DUNGEON_ID, title: DEFAULT_TEST_DUNGEON_TITLE, category: 'WoW' },
|
|
];
|
|
const routesToRun = routeSelection
|
|
? routes.filter((route) => route.id === routeSelection)
|
|
: routes.filter((route) => route.id === DEFAULT_TEST_DUNGEON_ID);
|
|
if (routeSelection && routesToRun.length !== 1) throw new Error(`Unknown native campaign route id: ${routeSelection}`);
|
|
if (routeSelection === 'wailing-caverns' && !historicalWailing) {
|
|
throw new Error('Wailing Caverns is historical-only; add --historical-wailing to run its explicit regression route.');
|
|
}
|
|
if (historicalWailing && routeSelection !== 'wailing-caverns') {
|
|
throw new Error('--historical-wailing requires --route=wailing-caverns.');
|
|
}
|
|
if (stockadeVowDiagnostic && (!campaignBossRoute || routeSelection !== DEFAULT_TEST_DUNGEON_ID)) {
|
|
throw new Error('--stockade-vow-diagnostic requires --campaign-boss-route --route=stormwind-stockade.');
|
|
}
|
|
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-native-routes-'));
|
|
const faceBindings = [
|
|
['face-bottom', 1], ['face-left', 2], ['face-top', 3], ['face-right', 4],
|
|
['r1', 5], ['r2', 6], ['dpad-left', 7], ['dpad-right', 8],
|
|
];
|
|
const actionSlots = ['primary', 'secondary', 'tertiary'].flatMap((layer) => faceBindings.map(([slot, digit]) => {
|
|
const bindingId = `${layer}:${slot}`;
|
|
const key = layer === 'primary' ? String(digit) : `${layer === 'secondary' ? 'Shift' : 'Alt'}+Digit${digit}`;
|
|
return [bindingId, key];
|
|
}));
|
|
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
|
|
? [{ bindingId, 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'));
|
|
const groupHealing = bound.find(({ ability, effects }) => ability.name === 'Group Heal'
|
|
&& ability.target === 'self' && effects.some((effect) => effect.kind === 'heal'));
|
|
const vowOfSilence = bound.find(({ ability }) => ability.dbcSpellId === 490275
|
|
|| ability.name === 'Vow of Silence');
|
|
const actionFor = (entry) => entry ? { bindingId: entry.bindingId, key: entry.key, id: entry.ability.id, name: entry.ability.name,
|
|
target: entry.ability.target, range: entry.ability.range ?? null,
|
|
castTimeMs: entry.ability.castTimeMs ?? null, cooldownMs: entry.ability.cooldownMs ?? null,
|
|
gcdMs: entry.ability.gcdMs ?? null, declaredSchool: entry.ability.castRules?.school ?? null } : null;
|
|
return {
|
|
damage: damage ? { key: damage.key, id: damage.ability.id, name: damage.ability.name,
|
|
target: damage.ability.target, range: damage.ability.range ?? null,
|
|
castTimeMs: damage.ability.castTimeMs ?? null, cooldownMs: damage.ability.cooldownMs ?? null,
|
|
gcdMs: damage.ability.gcdMs ?? null,
|
|
declaredSchool: damage.ability.castRules?.school ?? null,
|
|
selectionReason: 'longest-range-bound-hostile-damage-ability' } : null,
|
|
healing: actionFor(healing),
|
|
groupHealing: actionFor(groupHealing),
|
|
vowOfSilence: actionFor(vowOfSilence),
|
|
bound: bound.map(({ bindingId, key, ability }) => ({ bindingId, key, id: ability.id, name: ability.name,
|
|
target: ability.target, range: ability.range ?? null, castTimeMs: ability.castTimeMs ?? null,
|
|
cooldownMs: ability.cooldownMs ?? null, gcdMs: ability.gcdMs ?? null,
|
|
declaredSchool: ability.castRules?.school ?? null })),
|
|
};
|
|
}
|
|
|
|
async function readLiveState(page, includeActionSetup = false) {
|
|
return page.evaluate((includeSetup) => window.nativeRouteStores().then(({ game, combat, party, dungeonRun,
|
|
getMobPosition, mobPositionExports, getPartyPosition, partyPositionExports,
|
|
abilityDamageSchoolDiscovery }) => {
|
|
const gameState = game.getState();
|
|
const combatState = combat.getState();
|
|
const partyState = party.getState();
|
|
const now = Date.now();
|
|
const controlMechanic = combatState.controlMechanic ?? null;
|
|
const controlledUntil = combatState.controlledUntil ?? 0;
|
|
const controlPreventsActions = controlledUntil > now && controlMechanic !== null
|
|
&& !['root', 'slow', 'interrupt'].includes(controlMechanic);
|
|
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,
|
|
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,
|
|
cameraLookActive: Boolean(gameState.cameraLookActive),
|
|
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,
|
|
defeatedPromptVisible: Boolean(document.querySelector('.defeated-prompt')),
|
|
},
|
|
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),
|
|
nativeStoreDiscovery: { abilityDamageSchool: abilityDamageSchoolDiscovery },
|
|
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,
|
|
selectedFriendlyActorId: combatState.selectedFriendlyActorId ?? null,
|
|
partySelectedMemberId: partyState.selectedMemberId ?? null,
|
|
partySelfSelected: Boolean(partyState.selfSelected),
|
|
activeCast: combatState.activeCast?.abilityId ?? null,
|
|
controlledUntil,
|
|
controlMechanic,
|
|
actionBlockedByControl: controlPreventsActions,
|
|
cooldowns: combatState.cooldowns,
|
|
globalCooldownEndsAt: combatState.globalCooldownEndsAt ?? 0,
|
|
globalCooldownRemainingMs: Math.max(0, (combatState.globalCooldownEndsAt ?? 0) - now),
|
|
schoolLockedUntil: combatState.schoolLockedUntil ?? {},
|
|
actionBindings: combatState.actionBindings,
|
|
focusedElement: document.activeElement ? {
|
|
tagName: document.activeElement.tagName,
|
|
role: document.activeElement.getAttribute?.('role') ?? null,
|
|
ariaLabel: document.activeElement.getAttribute?.('aria-label') ?? null,
|
|
className: typeof document.activeElement.className === 'string'
|
|
? document.activeElement.className : null,
|
|
} : null,
|
|
},
|
|
partySelection: {
|
|
selectedMemberId: partyState.selectedMemberId ?? null,
|
|
selfSelected: Boolean(partyState.selfSelected),
|
|
},
|
|
party: partyState.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,
|
|
})),
|
|
combatAuras: (combatState.auras ?? []).filter((aura) => aura.definition?.id === 'rom-priest-vow-of-silence')
|
|
.map((aura) => ({ instanceId: aura.instanceId, definitionId: aura.definition.id,
|
|
sourceId: aura.sourceId, targetId: aura.targetId, appliedAt: aura.appliedAt,
|
|
expiresAt: aura.expiresAt })),
|
|
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,
|
|
school: mob.activeCast.school ?? null } : null,
|
|
schoolLockedUntil: mob.schoolLockedUntil ?? {},
|
|
statuses: (mob.statuses ?? []).map((status) => ({ kind: status.kind,
|
|
endsAt: status.endsAt, sourceAbilityId: status.sourceAbilityId ?? null,
|
|
sourceActorId: status.sourceActorId ?? 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);
|
|
}
|
|
|
|
async function pressRouteKey(page, key) {
|
|
const blocked = await page.evaluate(() => Boolean(document.querySelector('.defeated-prompt, .dungeon-results-backdrop')));
|
|
if (blocked) return false;
|
|
await page.keyboard.press(key);
|
|
return true;
|
|
}
|
|
|
|
async function resolveActionSchool(page, action) {
|
|
return page.evaluate(async (abilityId) => {
|
|
const { combat, abilityDamageSchool, abilityDamageSchoolDiscovery } = await window.nativeRouteStores();
|
|
const state = combat.getState();
|
|
const ability = state.abilities.find((entry) => entry.id === abilityId);
|
|
if (!ability) return { school: null, source: 'ability-not-found-in-native-runtime-state',
|
|
discovery: abilityDamageSchoolDiscovery };
|
|
if (ability.castRules?.school) return { school: ability.castRules.school,
|
|
source: 'ability.castRules.school', discovery: abilityDamageSchoolDiscovery };
|
|
if (typeof abilityDamageSchool === 'function') {
|
|
return { school: abilityDamageSchool(state.classId, ability),
|
|
source: 'minified-combatStore-pure-school-helper', discovery: abilityDamageSchoolDiscovery };
|
|
}
|
|
return { school: null, source: 'native-school-helper-unavailable', discovery: abilityDamageSchoolDiscovery };
|
|
}, action.id);
|
|
}
|
|
|
|
function sameObservedMobCast(left, right) {
|
|
return Boolean(left && right && left.attackId === right.attackId
|
|
&& left.startedAt === right.startedAt && left.completesAt === right.completesAt);
|
|
}
|
|
|
|
function isHamhockChainLightning(mob) {
|
|
return Boolean(mob && !mob.dead && mob.health > 0 && mob.name === DEFAULT_TEST_DUNGEON_BOSS
|
|
&& mob.activeCast?.interruptible
|
|
&& (mob.activeCast.spellId === 421 || mob.activeCast.attackId === 'spell-421'
|
|
|| /chain lightning/i.test(mob.activeCast.name ?? '')));
|
|
}
|
|
|
|
function markStockadeVowDeadline(diagnostic, snapshot, window) {
|
|
if (window) window.outcome = 'bounded-diagnostic-deadline-reached-before-vow-input';
|
|
if (diagnostic.status === 'watching') {
|
|
diagnostic.status = 'bounded-deadline-expired-without-confirmed-cancellation';
|
|
diagnostic.completedAt = snapshot.now;
|
|
diagnostic.stopReason = diagnostic.status;
|
|
}
|
|
}
|
|
|
|
function angleDelta(from, to) {
|
|
return Math.atan2(Math.sin(to - from), Math.cos(to - from));
|
|
}
|
|
|
|
async function turnNormallyToFaceVowTarget(page, diagnostic, routeResult, window, initialSnapshot,
|
|
targetId, watchedCast, collect) {
|
|
let current = initialSnapshot;
|
|
const targetFor = (snapshot) => snapshot.mobs.find((mob) => mob.id === targetId);
|
|
const targetYawFor = (snapshot, target) => Math.atan2(
|
|
target.position[0] - snapshot.player.position[0], target.position[2] - snapshot.player.position[2]);
|
|
const facingEvidence = { input: 'held right-mouse camera drag', sourceFacing: 'combatSpatialActor player yaw is game.cameraYaw',
|
|
yawRadiansPerPixel: -0.0021, originalCast: watchedCast, before: null, after: null };
|
|
window.facingTurn = facingEvidence;
|
|
const canIssue = (snapshot) => snapshot.player.health > 0 && !snapshot.game.defeatedPromptVisible
|
|
&& snapshot.game.overlay !== 'dungeon-results';
|
|
const sameLiveCast = (snapshot) => {
|
|
const target = targetFor(snapshot);
|
|
return target && !target.dead && target.health > 0 && sameObservedMobCast(target.activeCast, watchedCast)
|
|
? target : null;
|
|
};
|
|
const fail = (reason) => {
|
|
facingEvidence.outcome = reason;
|
|
return { snapshot: current, ready: false, facingEvidence };
|
|
};
|
|
|
|
if (Date.now() >= diagnostic.deadlineAt) {
|
|
markStockadeVowDeadline(diagnostic, current, window);
|
|
return fail('diagnostic-deadline-before-facing-turn');
|
|
}
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
let target = sameLiveCast(current);
|
|
if (!target || !canIssue(current)) return fail(!target
|
|
? 'original-chain-lightning-cast-not-live-before-facing-turn'
|
|
: 'player-dead-or-results-overlay-before-facing-turn');
|
|
const targetPosition = target.position;
|
|
if (!hasFinitePosition(current.player.position) || !hasFinitePosition(targetPosition)) {
|
|
return fail('player-or-hamhock-position-unavailable-for-facing-turn');
|
|
}
|
|
const desiredYaw = targetYawFor(current, target);
|
|
const yawBefore = current.game.cameraYaw;
|
|
const yawErrorBefore = angleDelta(yawBefore, desiredYaw);
|
|
const distanceBefore = distance3(current.player.position, targetPosition);
|
|
facingEvidence.before = { at: current.now, playerPosition: current.player.position,
|
|
targetPosition, distance: distanceBefore, cameraYaw: yawBefore, desiredYaw,
|
|
headingErrorRadians: yawErrorBefore, cast: target.activeCast,
|
|
cameraLookActive: current.game.cameraLookActive,
|
|
pointerLockElement: current.game.cameraProbe.pointerLockElement };
|
|
if (current.game.cameraLookActive) return fail('camera-look-was-already-active-before-facing-turn');
|
|
const facingArcLimit = Math.PI / 2 - 0.08;
|
|
const plannedResidualYaw = 1.25;
|
|
if (Math.abs(yawErrorBefore) <= facingArcLimit) {
|
|
facingEvidence.outcome = 'already-inside-hostile-facing-arc';
|
|
facingEvidence.after = { at: current.now, cameraYaw: yawBefore,
|
|
headingErrorRadians: yawErrorBefore, playerPosition: current.player.position,
|
|
distance: distanceBefore, cast: target.activeCast,
|
|
cameraLookActive: current.game.cameraLookActive,
|
|
pointerLockElement: current.game.cameraProbe.pointerLockElement };
|
|
return { snapshot: current, ready: true, facingEvidence };
|
|
}
|
|
|
|
const timeUntilRelease = watchedCast.completesAt - current.now;
|
|
let requestedYawDelta = yawErrorBefore - Math.sign(yawErrorBefore) * plannedResidualYaw;
|
|
let requestedMouseDx = -requestedYawDelta / 0.0021;
|
|
const viewport = page.viewportSize() ?? await page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
|
|
const canvasInfo = await page.evaluate(() => {
|
|
const canvas = document.querySelector('canvas');
|
|
if (!canvas) return { canvasFound: false };
|
|
const rect = canvas.getBoundingClientRect();
|
|
const margin = Math.min(18, Math.max(4, rect.width / 12));
|
|
return { canvasFound: true, rect: { left: rect.left, right: rect.right, top: rect.top,
|
|
bottom: rect.bottom, width: rect.width, height: rect.height }, margin,
|
|
maxHorizontalDrag: Math.max(0, rect.width - margin * 2) };
|
|
});
|
|
facingEvidence.canvas = canvasInfo;
|
|
if (!canvasInfo.canvasFound || canvasInfo.maxHorizontalDrag <= 0) {
|
|
return fail('active-canvas-unavailable-for-normal-facing-turn');
|
|
}
|
|
if (Math.abs(requestedMouseDx) > canvasInfo.maxHorizontalDrag) {
|
|
requestedMouseDx = Math.sign(requestedMouseDx) * canvasInfo.maxHorizontalDrag;
|
|
requestedYawDelta = -requestedMouseDx * 0.0021;
|
|
const expectedResidual = angleDelta(0, yawErrorBefore - requestedYawDelta);
|
|
facingEvidence.boundedPartialTurn = { reason: 'full turn exceeds active-canvas width',
|
|
plannedResidualYaw, maximumDragPixels: canvasInfo.maxHorizontalDrag,
|
|
requestedMouseDx, requestedYawDelta, expectedResidualYaw: expectedResidual };
|
|
if (Math.abs(expectedResidual) > facingArcLimit) {
|
|
return fail('active-canvas-width-cannot-reach-safe-hostile-facing-arc');
|
|
}
|
|
} else {
|
|
facingEvidence.boundedPartialTurn = { reason: 'minimum turn to enter hostile facing arc with margin',
|
|
plannedResidualYaw, requestedMouseDx, requestedYawDelta,
|
|
expectedResidualYaw: angleDelta(0, yawErrorBefore - requestedYawDelta) };
|
|
}
|
|
const frameSteps = Math.max(1, Math.ceil(Math.abs(requestedMouseDx) / 100));
|
|
const hitTest = await page.evaluate(({ dx, steps, rowFractions, margin }) => {
|
|
const canvas = document.querySelector('canvas');
|
|
if (!canvas) return { canvasFound: false, rows: [], selectedRow: null };
|
|
const rect = canvas.getBoundingClientRect();
|
|
const startX = dx >= 0 ? rect.left + margin : rect.right - margin;
|
|
const endX = startX + dx;
|
|
const pathSamples = Math.max(1, Math.ceil(Math.abs(dx) / 40));
|
|
const rows = rowFractions.map((fraction) => {
|
|
const y = Math.max(rect.top + margin, Math.min(rect.bottom - margin, rect.top + rect.height * fraction));
|
|
const points = Array.from({ length: pathSamples + 1 }, (_, index) => startX + (dx * index / pathSamples));
|
|
const hitElements = points.map((x) => {
|
|
const hit = document.elementFromPoint(x, y);
|
|
const isCanvas = Boolean(hit && (hit === canvas || canvas.contains(hit)));
|
|
return { x, y, isCanvas, tagName: hit?.tagName ?? null,
|
|
className: typeof hit?.className === 'string' ? hit.className : null,
|
|
ariaLabel: hit?.getAttribute?.('aria-label') ?? null };
|
|
});
|
|
return { fraction, y, startX, endX,
|
|
allPathPointsHitCanvas: hitElements.every((hit) => hit.isCanvas), hitElements };
|
|
});
|
|
const selectedRow = rows.find((row) => row.allPathPointsHitCanvas) ?? null;
|
|
return { canvasFound: true, rect: { left: rect.left, right: rect.right, top: rect.top,
|
|
bottom: rect.bottom, width: rect.width, height: rect.height }, margin,
|
|
pathSampleSpacingPx: Math.abs(dx) / pathSamples, requestedDx: dx, pathSamples,
|
|
rows, selectedRow: selectedRow ? { fraction: selectedRow.fraction, y: selectedRow.y } : null,
|
|
startX, endX };
|
|
}, { dx: requestedMouseDx, steps: frameSteps, margin: canvasInfo.margin,
|
|
rowFractions: [0.35, 0.5, 0.6, 0.65, 0.7] });
|
|
facingEvidence.hitTest = hitTest;
|
|
const selectedPathRow = hitTest.rows?.find((row) => row.allPathPointsHitCanvas) ?? null;
|
|
if (!hitTest.canvasFound || !selectedPathRow) {
|
|
return fail('no-sampled-horizontal-facing-drag-row-hits-active-canvas');
|
|
}
|
|
if (timeUntilRelease < 760) return fail('insufficient-cast-time-for-bounded-facing-turn-and-vow-input');
|
|
if (Math.abs(requestedMouseDx) / frameSteps > 120) {
|
|
facingEvidence.requestedMouseDx = requestedMouseDx;
|
|
facingEvidence.frameSteps = frameSteps;
|
|
return fail('facing-drag-increment-exceeds-safe-per-frame-bound');
|
|
}
|
|
if (Date.now() >= diagnostic.deadlineAt) {
|
|
markStockadeVowDeadline(diagnostic, current, window);
|
|
return fail('diagnostic-deadline-before-facing-drag');
|
|
}
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
target = sameLiveCast(current);
|
|
if (!target || !canIssue(current) || watchedCast.completesAt - current.now < 480) {
|
|
return fail(!target ? 'original-chain-lightning-cast-changed-before-facing-drag'
|
|
: !canIssue(current) ? 'player-dead-or-results-overlay-before-facing-drag'
|
|
: 'chain-lightning-too-close-to-release-before-facing-drag');
|
|
}
|
|
|
|
const startX = selectedPathRow.startX;
|
|
const endX = selectedPathRow.endX;
|
|
const y = selectedPathRow.y;
|
|
const dragStartedAt = Date.now();
|
|
facingEvidence.drag = { startedAt: dragStartedAt, start: [startX, y], end: [endX, y],
|
|
requestedMouseDx, requestedYawDelta,
|
|
expectedResidualYaw: angleDelta(0, yawErrorBefore - requestedYawDelta),
|
|
increments: frameSteps, maxPixelsPerFrame: 100, millisecondsBetweenFrames: 'one requestAnimationFrame' };
|
|
await page.mouse.move(startX, y);
|
|
if (Date.now() >= diagnostic.deadlineAt) {
|
|
markStockadeVowDeadline(diagnostic, current, window);
|
|
return fail('diagnostic-deadline-before-facing-mouse-down');
|
|
}
|
|
await page.mouse.down({ button: 'right' });
|
|
let dragFailureReason = null;
|
|
try {
|
|
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve(true))));
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
target = sameLiveCast(current);
|
|
if (Date.now() >= diagnostic.deadlineAt) {
|
|
markStockadeVowDeadline(diagnostic, current, window);
|
|
dragFailureReason = 'diagnostic-deadline-after-facing-mouse-down';
|
|
}
|
|
facingEvidence.mouseDown = { at: current.now, cameraLookActive: current.game.cameraLookActive,
|
|
pointerLockElement: current.game.cameraProbe.pointerLockElement,
|
|
playerAlive: current.player.health > 0, resultsOverlayOpen: current.game.overlay === 'dungeon-results',
|
|
cast: target?.activeCast ?? null, sameOriginalCast: Boolean(target) };
|
|
if (!dragFailureReason && current.game.cameraProbe.pointerLockElement !== null) {
|
|
dragFailureReason = 'pointer-lock-active-during-normal-facing-drag';
|
|
}
|
|
if (!dragFailureReason && !current.game.cameraLookActive) {
|
|
dragFailureReason = 'right-mouse-down-did-not-activate-camera-look';
|
|
}
|
|
if (!dragFailureReason && (!target || !canIssue(current) || watchedCast.completesAt - current.now < 600)) {
|
|
dragFailureReason = !target ? 'original-chain-lightning-cast-changed-before-facing-mouse-delta'
|
|
: !canIssue(current) ? 'player-dead-or-results-overlay-before-facing-mouse-delta'
|
|
: 'chain-lightning-too-close-to-release-before-facing-mouse-delta';
|
|
}
|
|
for (let index = 1; !dragFailureReason && index <= frameSteps; index += 1) {
|
|
if (Date.now() >= diagnostic.deadlineAt) {
|
|
markStockadeVowDeadline(diagnostic, current, window);
|
|
dragFailureReason = 'diagnostic-deadline-during-facing-drag';
|
|
break;
|
|
}
|
|
if (watchedCast.completesAt - Date.now() < 180) {
|
|
dragFailureReason = 'chain-lightning-release-too-close-during-facing-drag';
|
|
break;
|
|
}
|
|
const x = startX + requestedMouseDx * index / frameSteps;
|
|
await page.mouse.move(x, y);
|
|
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve(true))));
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
target = sameLiveCast(current);
|
|
if (!target || !canIssue(current)) {
|
|
dragFailureReason = !target ? 'original-chain-lightning-cast-changed-during-facing-drag'
|
|
: 'player-dead-or-results-overlay-during-facing-drag';
|
|
break;
|
|
}
|
|
}
|
|
} finally {
|
|
await page.mouse.up({ button: 'right' });
|
|
}
|
|
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve(true))));
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
target = sameLiveCast(current);
|
|
const yawAfter = current.game.cameraYaw;
|
|
const targetAfterYaw = target && hasFinitePosition(current.player.position)
|
|
&& hasFinitePosition(target.position) ? targetYawFor(current, target) : null;
|
|
const yawErrorAfter = targetAfterYaw === null ? null : angleDelta(yawAfter, targetAfterYaw);
|
|
facingEvidence.after = { at: current.now, playerPosition: current.player.position,
|
|
targetPosition: target?.position ?? null,
|
|
distance: target ? distance3(current.player.position, target.position) : null,
|
|
cameraYaw: yawAfter, desiredYaw: targetAfterYaw, headingErrorRadians: yawErrorAfter,
|
|
yawChangeRadians: angleDelta(yawBefore, yawAfter),
|
|
measuredYawDeltaErrorRadians: angleDelta(requestedYawDelta,
|
|
angleDelta(yawBefore, yawAfter)),
|
|
requestedYawDeltaMatchedWithinTolerance: Math.abs(angleDelta(requestedYawDelta,
|
|
angleDelta(yawBefore, yawAfter))) <= 0.15,
|
|
playerDisplacement: distance3(facingEvidence.before.playerPosition, current.player.position),
|
|
cast: target?.activeCast ?? null, sameOriginalCast: Boolean(target),
|
|
cameraLookActive: current.game.cameraLookActive,
|
|
pointerLockElement: current.game.cameraProbe.pointerLockElement,
|
|
elapsedMs: current.now - dragStartedAt };
|
|
if (dragFailureReason) return fail(dragFailureReason);
|
|
if (!target) return fail('original-chain-lightning-cast-changed-during-facing-drag');
|
|
if (!canIssue(current)) return fail('player-dead-or-results-overlay-after-facing-drag');
|
|
if (current.game.cameraLookActive) return fail('camera-look-remained-active-after-right-mouse-release');
|
|
if (current.game.cameraProbe.pointerLockElement !== null) return fail('pointer-lock-active-after-normal-facing-drag');
|
|
if (Date.now() >= diagnostic.deadlineAt) {
|
|
markStockadeVowDeadline(diagnostic, current, window);
|
|
return fail('diagnostic-deadline-after-facing-drag');
|
|
}
|
|
const insideArc = Math.abs(yawErrorAfter) <= facingArcLimit;
|
|
const yawDeltaMatches = facingEvidence.after.requestedYawDeltaMatchedWithinTolerance;
|
|
facingEvidence.outcome = insideArc && yawDeltaMatches
|
|
? 'ordinary-right-drag-placed-hamhock-inside-hostile-facing-arc'
|
|
: !yawDeltaMatches ? 'ordinary-right-drag-yaw-change-did-not-match-requested-turn'
|
|
: 'ordinary-right-drag-did-not-place-hamhock-inside-hostile-facing-arc';
|
|
return { snapshot: current, ready: insideArc && yawDeltaMatches, facingEvidence };
|
|
}
|
|
|
|
async function attemptStockadeVowInterrupt(page, routeResult, record, initialSnapshot, vowAction, collect) {
|
|
const diagnostic = routeResult.vowDiagnostic;
|
|
if (!stockadeVowDiagnostic || !diagnostic || diagnostic.status !== 'watching' || !vowAction) {
|
|
return { snapshot: initialSnapshot, attempted: false };
|
|
}
|
|
let current = initialSnapshot;
|
|
let hamhock = current.mobs.find((mob) => mob.id === diagnostic.targetBossId)
|
|
?? current.mobs.find((mob) => mob.name === DEFAULT_TEST_DUNGEON_BOSS && mob.boss);
|
|
if (!isHamhockChainLightning(hamhock)) return { snapshot: current, attempted: false };
|
|
diagnostic.hamhockObserved = true;
|
|
const watchedCast = { attackId: hamhock.activeCast.attackId,
|
|
startedAt: hamhock.activeCast.startedAt, completesAt: hamhock.activeCast.completesAt,
|
|
spellId: hamhock.activeCast.spellId, name: hamhock.activeCast.name,
|
|
school: hamhock.activeCast.school, interruptible: hamhock.activeCast.interruptible };
|
|
const castKey = `${hamhock.id}|${watchedCast.attackId}|${watchedCast.startedAt}|${watchedCast.completesAt}`;
|
|
if (diagnostic.observedCastKeys.includes(castKey)) return { snapshot: current, attempted: false };
|
|
diagnostic.observedCastKeys.push(castKey);
|
|
const window = { targetId: hamhock.id, targetName: hamhock.name, castBeforeTargeting: watchedCast,
|
|
observedAt: current.now, selectionAttempts: [], outcome: 'observed-live-interruptible-chain-lightning' };
|
|
diagnostic.observedWindows.push(window);
|
|
(record.vowWatchWindows ??= []).push(window);
|
|
|
|
const readSameLiveCast = (snapshot) => {
|
|
const target = snapshot.mobs.find((mob) => mob.id === hamhock.id);
|
|
return target && !target.dead && target.health > 0 && sameObservedMobCast(target.activeCast, watchedCast)
|
|
? target : null;
|
|
};
|
|
let liveTarget = readSameLiveCast(current);
|
|
if (!liveTarget) {
|
|
window.outcome = 'cast-lapsed-before-normal-target-selection';
|
|
return { snapshot: current, attempted: false, window };
|
|
}
|
|
for (let attempt = 0; current.player.selectedTargetId !== hamhock.id && attempt < 16; attempt += 1) {
|
|
if (current.game.overlay === 'dungeon-results' || current.game.defeatedPromptVisible || current.player.health <= 0) break;
|
|
if (Date.now() >= diagnostic.deadlineAt) {
|
|
markStockadeVowDeadline(diagnostic, current, window);
|
|
return { snapshot: current, attempted: false, window };
|
|
}
|
|
if (!await pressRouteKey(page, 'Tab')) break;
|
|
window.selectionAttempts.push({ at: Date.now(), key: 'Tab', selectedTargetIdBefore: current.player.selectedTargetId });
|
|
await page.waitForTimeout(45);
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
liveTarget = readSameLiveCast(current);
|
|
if (!liveTarget) {
|
|
window.outcome = current.mobs.find((mob) => mob.id === hamhock.id)?.dead
|
|
? 'target-defeated-during-normal-target-selection'
|
|
: 'cast-lapsed-or-changed-during-normal-target-selection';
|
|
return { snapshot: current, attempted: false, window };
|
|
}
|
|
}
|
|
if (current.player.selectedTargetId !== hamhock.id) {
|
|
window.outcome = 'hamhock-not-selected-by-normal-tab-targeting';
|
|
return { snapshot: current, attempted: false, window };
|
|
}
|
|
|
|
const facingTurn = await turnNormallyToFaceVowTarget(page, diagnostic, routeResult, window,
|
|
current, hamhock.id, watchedCast, collect);
|
|
current = facingTurn.snapshot;
|
|
liveTarget = readSameLiveCast(current);
|
|
if (diagnostic.status !== 'watching') return { snapshot: current, attempted: false, window };
|
|
if (!facingTurn.ready || !liveTarget) {
|
|
window.outcome = facingTurn.facingEvidence.outcome ?? 'facing-turn-not-ready-for-vow-input';
|
|
return { snapshot: current, attempted: false, window };
|
|
}
|
|
|
|
const schoolResolution = await resolveActionSchool(page, vowAction);
|
|
const school = schoolResolution.school ?? vowAction.declaredSchool ?? null;
|
|
const schoolSource = schoolResolution.school ? schoolResolution.source
|
|
: vowAction.declaredSchool ? 'actionPlan.castRules.school' : schoolResolution.source;
|
|
const ready = (state) => {
|
|
const target = state.mobs.find((mob) => mob.id === hamhock.id);
|
|
const targetCastStillCurrent = target && !target.dead && target.health > 0
|
|
&& sameObservedMobCast(target.activeCast, watchedCast);
|
|
const targetDistance = hasFinitePosition(target?.position)
|
|
? distance3(state.player.position, target.position) : null;
|
|
const maximumRange = Number(vowAction.range?.max ?? 30);
|
|
const desiredYaw = target && hasFinitePosition(target.position) && hasFinitePosition(state.player.position)
|
|
? Math.atan2(target.position[0] - state.player.position[0], target.position[2] - state.player.position[2]) : null;
|
|
const facingErrorRadians = desiredYaw === null ? null : angleDelta(state.game.cameraYaw, desiredYaw);
|
|
const targetWithinHostileFacingArc = facingErrorRadians !== null
|
|
&& Math.abs(facingErrorRadians) <= Math.PI / 2 - 0.08;
|
|
const playerCanIssueInput = state.player.health > 0 && !state.game.defeatedPromptVisible
|
|
&& state.game.overlay !== 'dungeon-results';
|
|
const remainingLocks = Object.entries(state.player.schoolLockedUntil ?? {})
|
|
.filter(([, endsAt]) => Number(endsAt) > state.now)
|
|
.map(([lockedSchool, endsAt]) => ({ school: lockedSchool, endsAt, remainingMs: endsAt - state.now }));
|
|
const relevantLockEndsAt = school ? Number(state.player.schoolLockedUntil?.[school] ?? 0) : 0;
|
|
return {
|
|
ready: playerCanIssueInput && targetCastStillCurrent && targetDistance !== null
|
|
&& targetDistance <= maximumRange - 0.5
|
|
&& targetWithinHostileFacingArc
|
|
&& state.player.selectedTargetId === hamhock.id
|
|
&& !state.player.activeCast && !state.player.actionBlockedByControl
|
|
&& state.player.globalCooldownRemainingMs === 0
|
|
&& Math.max(0, Number(state.player.cooldowns?.[vowAction.id] ?? 0) - state.now) === 0
|
|
&& (!school || Math.max(0, relevantLockEndsAt - state.now) === 0)
|
|
&& (Boolean(school) || remainingLocks.length === 0),
|
|
targetCastStillCurrent,
|
|
playerCanIssueInput,
|
|
targetAlive: Boolean(target && !target.dead && target.health > 0),
|
|
targetDistance, maximumRange, targetWithinCatalogRange: targetDistance !== null
|
|
&& targetDistance <= maximumRange - 0.5,
|
|
targetWithinHostileFacingArc, desiredFacingYaw: desiredYaw,
|
|
currentCameraYaw: state.game.cameraYaw, facingErrorRadians,
|
|
cameraLookActive: state.game.cameraLookActive,
|
|
selectedTargetId: state.player.selectedTargetId,
|
|
activeCast: state.player.activeCast,
|
|
controlledUntil: state.player.controlledUntil,
|
|
controlMechanic: state.player.controlMechanic,
|
|
actionBlockedByControl: state.player.actionBlockedByControl,
|
|
globalCooldownEndsAt: state.player.globalCooldownEndsAt,
|
|
globalCooldownRemainingMs: state.player.globalCooldownRemainingMs,
|
|
personalCooldownEndsAt: state.player.cooldowns?.[vowAction.id] ?? 0,
|
|
personalCooldownRemainingMs: Math.max(0, Number(state.player.cooldowns?.[vowAction.id] ?? 0) - state.now),
|
|
actionSchool: school, actionSchoolSource: schoolSource, actionSchoolResolution: schoolResolution,
|
|
schoolLockEndsAt: relevantLockEndsAt,
|
|
schoolLockRemainingMs: Math.max(0, relevantLockEndsAt - state.now),
|
|
otherActiveSchoolLocks: remainingLocks,
|
|
};
|
|
};
|
|
const readinessDeadline = Math.min(watchedCast.completesAt - 120, Date.now() + 2_000,
|
|
diagnostic.deadlineAt);
|
|
let gate = ready(current);
|
|
while (!gate.ready && Date.now() < readinessDeadline && Date.now() < diagnostic.deadlineAt) {
|
|
if (!gate.targetCastStillCurrent) break;
|
|
await page.waitForTimeout(50);
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
gate = ready(current);
|
|
}
|
|
window.readiness = gate;
|
|
if (Date.now() >= diagnostic.deadlineAt) {
|
|
markStockadeVowDeadline(diagnostic, current, window);
|
|
return { snapshot: current, attempted: false, window };
|
|
}
|
|
if (Date.now() >= readinessDeadline) {
|
|
window.outcome = 'chain-lightning-too-close-to-scheduled-release-before-vow-input';
|
|
return { snapshot: current, attempted: false, window };
|
|
}
|
|
if (!gate.ready) {
|
|
window.outcome = !gate.targetWithinCatalogRange ? 'target-outside-catalog-range-or-position-unavailable'
|
|
: gate.targetAlive && !gate.targetCastStillCurrent ? 'cast-lapsed-before-vow-was-ready'
|
|
: !gate.targetWithinHostileFacingArc ? 'target-outside-hostile-facing-arc-after-normal-turn'
|
|
: Date.now() >= readinessDeadline ? 'vow-not-ready-before-cast-deadline'
|
|
: 'vow-input-gate-not-satisfied';
|
|
return { snapshot: current, attempted: false, window };
|
|
}
|
|
|
|
// Refresh the full live state immediately before the normal bound keypress.
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
gate = ready(current);
|
|
window.readinessAtInput = gate;
|
|
liveTarget = readSameLiveCast(current);
|
|
if (Date.now() >= diagnostic.deadlineAt) {
|
|
markStockadeVowDeadline(diagnostic, current, window);
|
|
return { snapshot: current, attempted: false, window };
|
|
}
|
|
if (Date.now() >= readinessDeadline || !gate.ready || !liveTarget
|
|
|| current.player.selectedTargetId !== hamhock.id) {
|
|
window.outcome = 'live-cast-or-action-readiness-changed-before-input';
|
|
return { snapshot: current, attempted: false, window };
|
|
}
|
|
const schoolLockBefore = Number(liveTarget.schoolLockedUntil?.[watchedCast.school] ?? 0);
|
|
const ownedVowAurasBefore = current.combatAuras.filter((aura) => aura.sourceId === '__player__'
|
|
&& aura.targetId === hamhock.id && aura.definitionId === 'rom-priest-vow-of-silence');
|
|
const vowAuraExpiryBefore = Math.max(0, ...ownedVowAurasBefore.map((aura) => Number(aura.expiresAt ?? 0)));
|
|
const keyTimestamp = current.now;
|
|
const playerEventsBefore = current.events.filter((event) => event.sourceActorId === '__player__'
|
|
&& event.abilityId === vowAction.id && event.occurredAt >= keyTimestamp - 1);
|
|
window.input = { at: keyTimestamp, key: vowAction.key, abilityId: vowAction.id,
|
|
expectedTargetId: hamhock.id, selectedTargetId: current.player.selectedTargetId,
|
|
targetAlive: liveTarget.health > 0, targetHealth: liveTarget.health,
|
|
castAtInput: { attackId: liveTarget.activeCast.attackId, startedAt: liveTarget.activeCast.startedAt,
|
|
completesAt: liveTarget.activeCast.completesAt, school: liveTarget.activeCast.school },
|
|
schoolLockBefore, readiness: gate };
|
|
if (Date.now() >= diagnostic.deadlineAt) {
|
|
window.input = null;
|
|
markStockadeVowDeadline(diagnostic, current, window);
|
|
return { snapshot: current, attempted: false, window };
|
|
}
|
|
if (!await pressRouteKey(page, vowAction.key)) {
|
|
window.outcome = current.game.overlay === 'dungeon-results'
|
|
? 'results-overlay-blocked-vow-input' : 'defeated-prompt-blocked-vow-input';
|
|
return { snapshot: current, attempted: false, window };
|
|
}
|
|
let after = await readLiveState(page);
|
|
collect(after);
|
|
const afterSnapshots = [after];
|
|
while (Date.now() < watchedCast.completesAt && after.now < watchedCast.completesAt
|
|
&& after.now - keyTimestamp < 350) {
|
|
const afterMob = after.mobs.find((mob) => mob.id === hamhock.id);
|
|
if (afterMob && (afterMob.dead || afterMob.health <= 0 || !sameObservedMobCast(afterMob.activeCast, watchedCast))) break;
|
|
await page.waitForTimeout(35);
|
|
after = await readLiveState(page);
|
|
collect(after);
|
|
afterSnapshots.push(after);
|
|
}
|
|
const cancellationObservation = after;
|
|
const observedKey = await page.evaluate(({ at }) => (window.nativeRouteKeyLog ?? []).filter((event) =>
|
|
event.code === 'Digit5' && event.shiftKey && !event.altKey && !event.ctrlKey && event.at >= at - 2).slice(-2),
|
|
{ at: keyTimestamp });
|
|
const postTarget = cancellationObservation.mobs.find((mob) => mob.id === hamhock.id) ?? null;
|
|
const schoolLockAfter = Number(postTarget?.schoolLockedUntil?.[watchedCast.school] ?? 0);
|
|
let allAfterEventsById = new Map([...current.events, ...afterSnapshots.flatMap((snapshot) => snapshot.events)]
|
|
.filter((event) => event.id !== undefined && event.id !== null)
|
|
.map((event) => [event.id, event]));
|
|
const vowEvents = [...allAfterEventsById.values()].filter((event) =>
|
|
event.sourceActorId === '__player__' && event.abilityId === vowAction.id
|
|
&& event.kind === 'cast' && event.outcome === 'completed'
|
|
&& event.occurredAt >= keyTimestamp - 2 && event.targetActorId === hamhock.id);
|
|
const chainSpellIds = new Set([watchedCast.attackId,
|
|
watchedCast.spellId === null ? null : `spell-${watchedCast.spellId}`].filter(Boolean));
|
|
let chainDamageEvents = [...allAfterEventsById.values()].filter((event) =>
|
|
event.sourceActorId === hamhock.id && event.kind === 'damage'
|
|
&& chainSpellIds.has(event.abilityId) && event.occurredAt >= watchedCast.startedAt);
|
|
const ownedVowAurasAfter = cancellationObservation.combatAuras.filter((aura) => aura.sourceId === '__player__'
|
|
&& aura.targetId === hamhock.id && aura.definitionId === 'rom-priest-vow-of-silence');
|
|
const vowAuraExpiryAfter = Math.max(0, ...ownedVowAurasAfter.map((aura) => Number(aura.expiresAt ?? 0)));
|
|
const interruptionEvidence = {
|
|
targetAliveAfterInput: Boolean(postTarget && !postTarget.dead && postTarget.health > 0),
|
|
targetCastStillSameAfterInput: sameObservedMobCast(postTarget?.activeCast, watchedCast),
|
|
activeCastDisappearedBeforeScheduledEnd: Boolean(postTarget && !postTarget.dead && postTarget.health > 0
|
|
&& !sameObservedMobCast(postTarget.activeCast, watchedCast)
|
|
&& cancellationObservation.now < watchedCast.completesAt),
|
|
castSchool: watchedCast.school ?? null,
|
|
schoolLockBefore: schoolLockBefore,
|
|
schoolLockAfter,
|
|
matchingPlayerVowCastCompletedOnTarget: vowEvents.length > 0,
|
|
ownedVowSilenceAurasBeforeInput: ownedVowAurasBefore,
|
|
ownedVowSilenceAurasAfterInput: ownedVowAurasAfter,
|
|
newOrRefreshedOwnedVowSilenceAura: ownedVowAurasAfter.some((aura) =>
|
|
aura.appliedAt >= keyTimestamp - 2 && Number(aura.expiresAt ?? 0) > vowAuraExpiryBefore
|
|
&& Number(aura.expiresAt ?? 0) > keyTimestamp),
|
|
schoolLockAdvancedAfterInput: schoolLockAfter > schoolLockBefore && schoolLockAfter > keyTimestamp,
|
|
matchingChainLightningDamageEventsForObservedCast: chainDamageEvents.map((event) => ({
|
|
id: event.id, occurredAt: event.occurredAt, abilityId: event.abilityId,
|
|
targetActorId: event.targetActorId, effectiveAmount: event.effectiveAmount,
|
|
})),
|
|
observedChainLightningHadNotResolvedDamage: chainDamageEvents.length === 0,
|
|
playerVowEvents: vowEvents.map((event) => ({ occurredAt: event.occurredAt, kind: event.kind,
|
|
id: event.id, outcome: event.outcome ?? null, targetActorId: event.targetActorId,
|
|
abilityId: event.abilityId })),
|
|
};
|
|
let confirmed = Boolean(observedKey.length && interruptionEvidence.targetAliveAfterInput
|
|
&& interruptionEvidence.activeCastDisappearedBeforeScheduledEnd
|
|
&& interruptionEvidence.matchingPlayerVowCastCompletedOnTarget
|
|
&& interruptionEvidence.newOrRefreshedOwnedVowSilenceAura
|
|
&& interruptionEvidence.observedChainLightningHadNotResolvedDamage);
|
|
let followThrough = null;
|
|
if (confirmed) {
|
|
const requiredThroughAt = watchedCast.completesAt + 100;
|
|
const followThroughHardStopAt = Math.min(requiredThroughAt, Date.now() + 1_600, diagnostic.deadlineAt);
|
|
const followSamples = [];
|
|
while (Date.now() < followThroughHardStopAt && after.now < requiredThroughAt) {
|
|
await page.waitForTimeout(Math.max(1, Math.min(35, followThroughHardStopAt - Date.now())));
|
|
after = await readLiveState(page);
|
|
collect(after);
|
|
afterSnapshots.push(after);
|
|
const followTarget = after.mobs.find((mob) => mob.id === hamhock.id) ?? null;
|
|
const followChainDamage = after.events.filter((event) => event.sourceActorId === hamhock.id
|
|
&& event.kind === 'damage' && chainSpellIds.has(event.abilityId)
|
|
&& event.occurredAt >= watchedCast.startedAt).map((event) => ({
|
|
id: event.id ?? null, occurredAt: event.occurredAt, abilityId: event.abilityId,
|
|
targetActorId: event.targetActorId, effectiveAmount: event.effectiveAmount,
|
|
}));
|
|
followSamples.push({ at: after.now, targetHealth: followTarget?.health ?? null,
|
|
targetDead: followTarget?.dead ?? null, activeCast: followTarget?.activeCast ?? null,
|
|
matchingChainDamageEvents: followChainDamage, playerHealth: after.player.health,
|
|
resultsOverlayOpen: after.game.overlay === 'dungeon-results' });
|
|
}
|
|
allAfterEventsById = new Map([...current.events, ...afterSnapshots.flatMap((snapshot) => snapshot.events)]
|
|
.filter((event) => event.id !== undefined && event.id !== null)
|
|
.map((event) => [event.id, event]));
|
|
chainDamageEvents = [...allAfterEventsById.values()].filter((event) =>
|
|
event.sourceActorId === hamhock.id && event.kind === 'damage'
|
|
&& chainSpellIds.has(event.abilityId) && event.occurredAt >= watchedCast.startedAt);
|
|
const sampledThroughReleasePlus100 = after.now >= requiredThroughAt;
|
|
followThrough = { readOnly: true, gameplayInputsStopped: true,
|
|
scheduledCastReleaseAt: watchedCast.completesAt, requiredSampleThroughAt: requiredThroughAt,
|
|
hardStopAt: followThroughHardStopAt, sampledThroughReleasePlus100,
|
|
lastSampleAt: after.now, sampleCount: followSamples.length, samples: followSamples,
|
|
matchingChainDamageEventsForObservedCast: chainDamageEvents.map((event) => ({
|
|
id: event.id, occurredAt: event.occurredAt, abilityId: event.abilityId,
|
|
targetActorId: event.targetActorId, effectiveAmount: event.effectiveAmount,
|
|
})) };
|
|
interruptionEvidence.postReleaseFollowThrough = followThrough;
|
|
interruptionEvidence.matchingChainLightningDamageEventsForObservedCast = followThrough.matchingChainDamageEventsForObservedCast;
|
|
interruptionEvidence.observedChainLightningHadNotResolvedDamage = chainDamageEvents.length === 0
|
|
&& sampledThroughReleasePlus100;
|
|
confirmed = Boolean(confirmed && sampledThroughReleasePlus100 && chainDamageEvents.length === 0);
|
|
if (!sampledThroughReleasePlus100 && Date.now() >= diagnostic.deadlineAt) {
|
|
markStockadeVowDeadline(diagnostic, after, window);
|
|
}
|
|
}
|
|
const attempt = { watchedCast, inputAt: keyTimestamp, key: vowAction.key, abilityId: vowAction.id,
|
|
selectedTargetId: current.player.selectedTargetId, targetAliveBeforeInput: liveTarget.health > 0,
|
|
facingTurn: window.facingTurn,
|
|
playerEventsBefore, inputKeyEvents: observedKey, feedbackBeforeInput: current.feedback,
|
|
feedbackAfterInput: cancellationObservation.feedback,
|
|
cancellationObservedAt: cancellationObservation.now,
|
|
afterReadAt: cancellationObservation.now, castAfter: postTarget?.activeCast ?? null,
|
|
targetHealthAfter: postTarget?.health ?? null, schoolLockBefore, schoolLockAfter,
|
|
interruptionEvidence, postReleaseFollowThrough: followThrough, confirmedCancellation: confirmed,
|
|
outcome: confirmed ? 'vow-interrupt-confirmed-before-scheduled-cast-release'
|
|
: 'vow-input-observed-but-interrupt-not-confirmed' };
|
|
diagnostic.attempts.push(attempt);
|
|
window.attempt = attempt;
|
|
window.outcome = attempt.outcome;
|
|
routeResult.actions.push({ ...attempt, kind: 'targeted-vow-interrupt', intendedTargetId: hamhock.id });
|
|
if (confirmed) {
|
|
diagnostic.status = 'confirmed-live-chain-lightning-cancellation';
|
|
diagnostic.confirmedCancellation = attempt;
|
|
diagnostic.completedAt = after.now;
|
|
diagnostic.stopReason = 'confirmed live Vow of Silence interrupt; stopped route inputs immediately';
|
|
if (record) record.vowDiagnosticStop = true;
|
|
}
|
|
return { snapshot: after, attempted: true, confirmed, window, attempt };
|
|
}
|
|
|
|
async function recoverPlayerThroughDefeatUi(page, routeResult, collect, context) {
|
|
let before = await readLiveState(page);
|
|
collect?.(before);
|
|
if (before.player.health > 0) return { recovered: false, reason: 'player-already-alive', snapshot: before };
|
|
const prompt = page.locator('.defeated-prompt');
|
|
if (!await prompt.isVisible().catch(() => false)) {
|
|
await prompt.waitFor({ state: 'visible', timeout: 3_000 }).catch(() => {});
|
|
}
|
|
if (!await prompt.isVisible().catch(() => false)) {
|
|
return { recovered: false, reason: 'normal-defeated-prompt-not-visible', snapshot: before };
|
|
}
|
|
before = await readLiveState(page);
|
|
collect?.(before);
|
|
const selector = prompt.locator('select');
|
|
const options = await selector.locator('option').evaluateAll((items) => items.map((option) => ({
|
|
value: option.value, label: option.textContent?.trim() ?? '', disabled: option.disabled,
|
|
})));
|
|
const availableValues = new Set(options.filter((option) => !option.disabled).map((option) => option.value));
|
|
const killedBossIds = before.dungeonRun?.killedBossIds ?? [];
|
|
const routeCheckpoints = routeResult.navigationGuidance?.checkpoints ?? [];
|
|
const routeIndexFor = (value) => value === 'entrance' ? 0
|
|
: routeCheckpoints.findIndex((checkpoint) => checkpoint.objectiveId === value.slice('boss:'.length));
|
|
const progressCheckpoint = [...killedBossIds].reverse().map((bossId) => `boss:${bossId}`)
|
|
.find((value) => availableValues.has(value) && routeIndexFor(value) >= 0);
|
|
const latestAuthoredCheckpoint = [...availableValues].filter((value) => value.startsWith('boss:')
|
|
&& routeIndexFor(value) >= 0).at(-1);
|
|
const preferredCheckpoint = progressCheckpoint ?? latestAuthoredCheckpoint;
|
|
const selectedValue = preferredCheckpoint ?? (availableValues.has('entrance') ? 'entrance' : null);
|
|
const checkpointSelectionBasis = progressCheckpoint ? 'latest-native-killedBossId-present-in-UI'
|
|
: latestAuthoredCheckpoint ? 'last-authored-boss-checkpoint-option-with-route-anchor-in-normal-UI'
|
|
: 'normal-UI-entrance-fallback';
|
|
if (!selectedValue) {
|
|
return { recovered: false, reason: 'no-enabled-authored-resurrection-checkpoint-in-normal-ui', snapshot: before,
|
|
options, killedBossIds };
|
|
}
|
|
const transitionIndex = (routeResult.playerDeathTransitions?.length ?? 0) + 1;
|
|
const screenshot = `player-defeated-${transitionIndex}.png`;
|
|
await page.screenshot({ path: path.join(outputDirectory, screenshot) }).catch(() => {});
|
|
await selector.selectOption(selectedValue);
|
|
const selectedCheckpoint = await selector.locator('option:checked').textContent().catch(() => null);
|
|
const selectedAt = await readLiveState(page);
|
|
collect?.(selectedAt);
|
|
const resumeButton = prompt.getByRole('button', {
|
|
name: selectedValue === 'entrance' ? 'Respawn at entrance' : 'Respawn at checkpoint', exact: true,
|
|
});
|
|
if (!await resumeButton.isEnabled().catch(() => false)) {
|
|
return { recovered: false, reason: 'normal-respawn-button-disabled', snapshot: selectedAt,
|
|
selectedValue, selectedCheckpoint, options, screenshot };
|
|
}
|
|
const inputAt = Date.now();
|
|
await resumeButton.click();
|
|
let after = selectedAt;
|
|
const respawnDeadline = Date.now() + 15_000;
|
|
while (Date.now() < respawnDeadline) {
|
|
await page.waitForTimeout(200);
|
|
after = await readLiveState(page);
|
|
collect?.(after);
|
|
if (after.player.health > 0 && !after.game.defeatedPromptVisible) break;
|
|
}
|
|
const recovered = after.player.health > 0 && !after.game.defeatedPromptVisible;
|
|
const killedBossIdsAfter = after.dungeonRun?.killedBossIds ?? [];
|
|
const newlyKilledDuringRecovery = killedBossIdsAfter.filter((bossId) => !killedBossIds.includes(bossId));
|
|
const sameRunIdentityAvailable = Boolean(before.dungeonRun && after.dungeonRun
|
|
&& before.dungeonRun.dungeonId && after.dungeonRun.dungeonId
|
|
&& before.dungeonRun.startedAt != null && after.dungeonRun.startedAt != null);
|
|
const sameRunIdentityPreserved = sameRunIdentityAvailable
|
|
? before.dungeonRun.dungeonId === after.dungeonRun.dungeonId
|
|
&& before.dungeonRun.startedAt === after.dungeonRun.startedAt
|
|
: null;
|
|
const priorBossProgressPreserved = killedBossIds.every((bossId) => killedBossIdsAfter.includes(bossId));
|
|
const transition = {
|
|
context, detectedAt: before.now, inputAt, completedAt: after.now,
|
|
checkpointValue: selectedValue, checkpointLabel: selectedCheckpoint,
|
|
checkpointChoices: options, checkpointSelectionBasis, screenshot,
|
|
selectedThroughNormalUi: true,
|
|
normalRespawnButtonClicked: true,
|
|
partyFightActiveAtInput: before.party.some((member) => member.health > 0)
|
|
&& before.mobs.some((mob) => mob.engaged && !mob.dead && !mob.despawned),
|
|
killedBossIdsBefore: killedBossIds,
|
|
killedBossIdsAfter,
|
|
newlyKilledDuringRecovery,
|
|
priorBossProgressPreserved,
|
|
sameRunIdentityAvailable,
|
|
sameRunIdentityPreserved,
|
|
dungeonProgressPreserved: priorBossProgressPreserved
|
|
&& (sameRunIdentityPreserved === null || sameRunIdentityPreserved),
|
|
playerHealthBefore: before.player.health,
|
|
playerHealthAfter: after.player.health,
|
|
playerPositionBefore: before.player.position,
|
|
playerPositionAfter: after.player.position,
|
|
overlayAfter: after.game.overlay,
|
|
recovered,
|
|
reason: recovered ? 'normal-respawn-ui-closed-defeated-prompt' : 'normal-respawn-did-not-restore-player-before-timeout',
|
|
};
|
|
const resumeFromCheckpointIndex = routeIndexFor(selectedValue);
|
|
const interruptedCheckpointIndex = Number.isInteger(routeResult.activeNavigationCheckpointIndex)
|
|
? routeResult.activeNavigationCheckpointIndex : routeCheckpoints.length - 1;
|
|
transition.routeReplayCursor = {
|
|
resumeFromCheckpointIndex: resumeFromCheckpointIndex >= 0 ? resumeFromCheckpointIndex : null,
|
|
interruptedCheckpointIndex,
|
|
authoredCheckpointMatched: selectedValue === 'entrance' || resumeFromCheckpointIndex >= 0,
|
|
orderedReplayRequired: recovered && routeCheckpoints.length > 0,
|
|
};
|
|
(routeResult.playerDeathTransitions ??= []).push(transition);
|
|
(routeResult.playerRecoveryActions ??= []).push({
|
|
at: inputAt, control: selectedValue === 'entrance' ? 'Respawn at entrance' : 'Respawn at checkpoint',
|
|
checkpointValue: selectedValue, checkpointLabel: selectedCheckpoint, checkpointSelectionBasis,
|
|
context, screenshot, recovered,
|
|
});
|
|
if (recovered && routeCheckpoints.length > 0) {
|
|
routeResult.pendingRouteReplay = {
|
|
id: `route-replay-${(routeResult.routeReplays?.length ?? 0) + 1}`,
|
|
requestedAt: after.now,
|
|
context,
|
|
selectedCheckpointValue: selectedValue,
|
|
selectedCheckpointLabel: selectedCheckpoint,
|
|
resumeFromCheckpointIndex: resumeFromCheckpointIndex >= 0 ? resumeFromCheckpointIndex : 0,
|
|
interruptedCheckpointIndex,
|
|
authoredCheckpointMatched: selectedValue === 'entrance' || resumeFromCheckpointIndex >= 0,
|
|
status: 'pending-ordered-waypoint-replay',
|
|
checkpointVisits: [],
|
|
movement: [],
|
|
};
|
|
}
|
|
return { recovered, reason: transition.reason, snapshot: after, transition };
|
|
}
|
|
|
|
function beginPendingRouteReplay(routeResult) {
|
|
const pending = routeResult.pendingRouteReplay;
|
|
if (!pending) return null;
|
|
delete routeResult.pendingRouteReplay;
|
|
const previousReplay = routeResult.routeReplays?.at(-1);
|
|
if (previousReplay?.status === 'replaying-ordered-waypoints') {
|
|
previousReplay.status = 'interrupted-by-another-player-death';
|
|
previousReplay.endedAt = Date.now();
|
|
previousReplay.subsequentReplayId = pending.id;
|
|
}
|
|
pending.status = 'replaying-ordered-waypoints';
|
|
pending.startedAt = Date.now();
|
|
pending.movementStartIndex = routeResult.movement.length;
|
|
routeResult.routeReplays ??= [];
|
|
routeResult.routeReplays.push(pending);
|
|
return pending;
|
|
}
|
|
|
|
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) {
|
|
const before = await readLiveState(page);
|
|
if (before.game.overlay === 'dungeon-results' || before.game.defeatedPromptVisible || before.player.health <= 0) break;
|
|
if (!await pressRouteKey(page, 'Tab')) break;
|
|
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);
|
|
if (before.game.overlay === 'dungeon-results' || before.game.defeatedPromptVisible || before.player.health <= 0) return null;
|
|
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);
|
|
const heldStartedAt = Date.now();
|
|
let interruptedByOverlay = false;
|
|
try {
|
|
while (Date.now() - heldStartedAt < durationMs) {
|
|
const remaining = durationMs - (Date.now() - heldStartedAt);
|
|
await page.waitForTimeout(Math.min(100, remaining));
|
|
const blocked = await page.evaluate(() => Boolean(document.querySelector('.defeated-prompt, .dungeon-results-backdrop')));
|
|
if (blocked) { interruptedByOverlay = true; break; }
|
|
const latest = await readLiveState(page);
|
|
if (latest.player.health <= 0) { interruptedByOverlay = true; break; }
|
|
}
|
|
} finally {
|
|
for (const key of keys) await page.keyboard.up(key);
|
|
}
|
|
if (!interruptedByOverlay) await page.waitForTimeout(150);
|
|
const after = await readLiveState(page);
|
|
const step = {
|
|
at: after.now,
|
|
keys: keys.map((key) => key.toUpperCase()),
|
|
durationMs: Date.now() - heldStartedAt,
|
|
requestedDurationMs: durationMs,
|
|
interruptedByOverlay,
|
|
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);
|
|
const activeReplay = routeResult.routeReplays?.at(-1);
|
|
if (activeReplay?.status === 'replaying-ordered-waypoints') activeReplay.movement.push(step);
|
|
return { snapshot: after, step };
|
|
}
|
|
|
|
async function performCast(page, routeResult, action, kind, intendedTargetId, reason) {
|
|
const actionSchoolResolution = await resolveActionSchool(page, action);
|
|
const actionSchool = actionSchoolResolution.school ?? action.declaredSchool ?? null;
|
|
const actionSchoolSource = actionSchoolResolution.school ? actionSchoolResolution.source
|
|
: action.declaredSchool ? 'actionPlan.castRules.school' : actionSchoolResolution.source;
|
|
const activeSchoolLocks = (state) => Object.entries(state.player.schoolLockedUntil ?? {})
|
|
.filter(([, lockedUntil]) => Number(lockedUntil) > state.now)
|
|
.map(([school, lockedUntil]) => ({ school, lockedUntil, remainingMs: Number(lockedUntil) - state.now }));
|
|
let before = await readLiveState(page);
|
|
if (before.game.overlay === 'dungeon-results' || before.game.defeatedPromptVisible) {
|
|
return { before, after: before, cast: {
|
|
at: before.now, key: action.key, abilityId: action.id, abilityName: action.name, kind, reason,
|
|
intendedTargetId, selectedTargetIdBefore: before.player.selectedTargetId,
|
|
partySelectedMemberIdBefore: before.player.partySelectedMemberId,
|
|
partySelfSelectedBefore: before.player.partySelfSelected,
|
|
playerPositionAtInput: before.player.position, actionAttempted: false,
|
|
actionAccepted: false, actionResolved: false,
|
|
outcome: before.game.overlay === 'dungeon-results' ? 'skipped-results-overlay-open' : 'skipped-defeated-prompt-open',
|
|
feedback: before.feedback,
|
|
} };
|
|
}
|
|
// Player actions are issued only when the player is clear of action-blocking
|
|
// control, an active cast, and both personal/global cooldowns. This is
|
|
// read-only pacing: it waits for the normal game gates to clear.
|
|
const readyWaitStartedAt = Date.now();
|
|
let readinessTimedOut = false;
|
|
{
|
|
while (before.game.overlay !== 'dungeon-results' && !before.game.defeatedPromptVisible) {
|
|
const actionReady = (state) => !state.player.activeCast
|
|
&& !state.player.actionBlockedByControl
|
|
&& state.player.globalCooldownRemainingMs === 0
|
|
&& Math.max(0, Number(state.player.cooldowns?.[action.id] ?? 0) - state.now) === 0
|
|
&& (!actionSchool || Math.max(0, Number(state.player.schoolLockedUntil?.[actionSchool] ?? 0) - state.now) === 0)
|
|
&& (Boolean(actionSchool) || activeSchoolLocks(state).length === 0);
|
|
if (actionReady(before)) {
|
|
// Refresh immediately before the keypress: runtime input validation uses
|
|
// Date.now(), so any positive sampled remainder (even <50ms) is early.
|
|
before = await readLiveState(page);
|
|
if (before.game.overlay === 'dungeon-results' || before.game.defeatedPromptVisible || actionReady(before)) break;
|
|
}
|
|
if (Date.now() - readyWaitStartedAt >= 5_000) {
|
|
readinessTimedOut = true;
|
|
break;
|
|
}
|
|
await page.waitForTimeout(100);
|
|
before = await readLiveState(page);
|
|
}
|
|
if (before.game.overlay === 'dungeon-results' || before.game.defeatedPromptVisible) {
|
|
return { before, after: before, cast: {
|
|
at: before.now, key: action.key, abilityId: action.id, abilityName: action.name, kind, reason,
|
|
intendedTargetId, selectedTargetIdBefore: before.player.selectedTargetId,
|
|
partySelectedMemberIdBefore: before.player.partySelectedMemberId,
|
|
partySelfSelectedBefore: before.player.partySelfSelected,
|
|
selectedFriendlyActorIdBefore: before.player.selectedFriendlyActorId,
|
|
playerPositionAtInput: before.player.position, actionAttempted: false,
|
|
actionAccepted: false, actionResolved: false,
|
|
outcome: before.game.overlay === 'dungeon-results' ? 'skipped-results-overlay-open' : 'skipped-defeated-prompt-open',
|
|
feedback: before.feedback,
|
|
} };
|
|
}
|
|
if (kind === 'healing' && intendedTargetId !== '__player__'
|
|
&& before.party.find((member) => member.id === intendedTargetId)?.health <= 0) {
|
|
return { before, after: before, cast: {
|
|
at: before.now, key: action.key, abilityId: action.id, abilityName: action.name, kind, reason,
|
|
intendedTargetId, selectedTargetIdBefore: before.player.selectedTargetId,
|
|
partySelectedMemberIdBefore: before.player.partySelectedMemberId,
|
|
partySelfSelectedBefore: before.player.partySelfSelected,
|
|
selectedFriendlyActorIdBefore: before.player.selectedFriendlyActorId,
|
|
targetHealthBefore: before.party.find((member) => member.id === intendedTargetId)?.health ?? null,
|
|
playerPositionAtInput: before.player.position, actionAttempted: false, actionAccepted: false,
|
|
actionResolved: false, outcome: 'target-down-after-readiness-wait; input-not-sent',
|
|
feedback: before.feedback,
|
|
} };
|
|
}
|
|
}
|
|
if (readinessTimedOut) {
|
|
const boundKeyEntry = Object.entries(before.player.actionBindings ?? {})
|
|
.find(([bindingId, abilityId]) => abilityId === action.id
|
|
&& (!action.bindingId || bindingId === action.bindingId)) ?? null;
|
|
return { before, after: before, cast: {
|
|
at: before.now, key: action.key, abilityId: action.id, abilityName: action.name, kind, reason,
|
|
intendedTargetId, selectedTargetIdBefore: before.player.selectedTargetId,
|
|
selectedFriendlyActorIdBefore: before.player.selectedFriendlyActorId,
|
|
playerPositionAtInput: before.player.position, actionAttempted: false, actionAccepted: false,
|
|
actionResolved: false, outcome: 'action-readiness-timeout; input-not-sent',
|
|
actionBindingAtInput: boundKeyEntry ? { bindingId: boundKeyEntry[0], abilityId: boundKeyEntry[1] } : null,
|
|
expectedBinding: boundKeyEntry ? boundKeyEntry[1] === action.id : false,
|
|
readiness: {
|
|
waitedMs: Date.now() - readyWaitStartedAt, timedOut: true,
|
|
activeCastAtInput: before.player.activeCast,
|
|
globalCooldownEndsAt: before.player.globalCooldownEndsAt,
|
|
globalCooldownRemainingMs: before.player.globalCooldownRemainingMs,
|
|
personalCooldownEndsAt: before.player.cooldowns?.[action.id] ?? 0,
|
|
personalCooldownRemainingMs: Math.max(0, (before.player.cooldowns?.[action.id] ?? 0) - before.now),
|
|
controlledUntil: before.player.controlledUntil,
|
|
controlMechanic: before.player.controlMechanic,
|
|
actionBlockedByControl: before.player.actionBlockedByControl,
|
|
actionSchool,
|
|
actionSchoolSource,
|
|
actionSchoolResolution,
|
|
schoolLockEndsAt: actionSchool ? before.player.schoolLockedUntil?.[actionSchool] ?? 0 : 0,
|
|
schoolLockRemainingMs: actionSchool
|
|
? Math.max(0, Number(before.player.schoolLockedUntil?.[actionSchool] ?? 0) - before.now) : 0,
|
|
activeSchoolLocksAtInput: activeSchoolLocks(before),
|
|
schoolLockBlockedBecauseSchoolUnknown: !actionSchool && activeSchoolLocks(before).length > 0,
|
|
partySelectedMemberId: before.player.partySelectedMemberId,
|
|
partySelfSelected: before.player.partySelfSelected,
|
|
focusAtInput: before.player.focusedElement,
|
|
},
|
|
feedback: before.feedback,
|
|
} };
|
|
}
|
|
const since = before.now - 1;
|
|
const boundKeyEntry = Object.entries(before.player.actionBindings ?? {})
|
|
.find(([bindingId, abilityId]) => abilityId === action.id
|
|
&& (!action.bindingId || bindingId === action.bindingId)) ?? null;
|
|
if (!await pressRouteKey(page, action.key)) {
|
|
const blockedState = await readLiveState(page);
|
|
const cast = {
|
|
at: before.now, key: action.key, abilityId: action.id, abilityName: action.name, kind, reason,
|
|
intendedTargetId, selectedTargetIdBefore: before.player.selectedTargetId,
|
|
selectedFriendlyActorIdBefore: before.player.selectedFriendlyActorId,
|
|
partySelectedMemberIdBefore: before.player.partySelectedMemberId,
|
|
partySelfSelectedBefore: before.player.partySelfSelected,
|
|
actionBindingAtInput: boundKeyEntry ? { bindingId: boundKeyEntry[0], abilityId: boundKeyEntry[1] } : null,
|
|
expectedBinding: Boolean(boundKeyEntry && boundKeyEntry[1] === action.id),
|
|
actionAttempted: false, actionAccepted: false, actionResolved: false,
|
|
outcome: blockedState.game.overlay === 'dungeon-results' ? 'skipped-results-overlay-open' : 'skipped-defeated-prompt-open',
|
|
feedback: blockedState.feedback,
|
|
};
|
|
routeResult.actions.push(cast);
|
|
return { before, after: blockedState, cast };
|
|
}
|
|
const castWaitStarted = Date.now();
|
|
let after = await readLiveState(page);
|
|
const feedbackImmediatelyAfterInput = after.feedback;
|
|
const inputStateTrace = [];
|
|
const captureInputState = (state) => {
|
|
const member = state.party.find((entry) => entry.id === intendedTargetId);
|
|
inputStateTrace.push({
|
|
at: state.now,
|
|
overlay: state.game.overlay,
|
|
defeatedPromptVisible: state.game.defeatedPromptVisible,
|
|
activeCast: state.player.activeCast,
|
|
selectedFriendlyActorId: state.player.selectedFriendlyActorId,
|
|
partySelectedMemberId: state.player.partySelectedMemberId,
|
|
partySelfSelected: state.player.partySelfSelected,
|
|
globalCooldownEndsAt: state.player.globalCooldownEndsAt,
|
|
globalCooldownRemainingMs: state.player.globalCooldownRemainingMs,
|
|
personalCooldownEndsAt: state.player.cooldowns?.[action.id] ?? 0,
|
|
personalCooldownRemainingMs: Math.max(0, Number(state.player.cooldowns?.[action.id] ?? 0) - state.now),
|
|
controlledUntil: state.player.controlledUntil,
|
|
controlMechanic: state.player.controlMechanic,
|
|
actionBlockedByControl: state.player.actionBlockedByControl,
|
|
actionSchool,
|
|
actionSchoolSource,
|
|
schoolHelperDiscovery: actionSchoolResolution.discovery,
|
|
schoolLockEndsAt: actionSchool ? state.player.schoolLockedUntil?.[actionSchool] ?? 0 : 0,
|
|
schoolLockRemainingMs: actionSchool
|
|
? Math.max(0, Number(state.player.schoolLockedUntil?.[actionSchool] ?? 0) - state.now) : 0,
|
|
activeSchoolLocks: activeSchoolLocks(state),
|
|
schoolLockBlockedBecauseSchoolUnknown: !actionSchool && activeSchoolLocks(state).length > 0,
|
|
playerHealth: state.player.health,
|
|
playerResource: state.player.resource,
|
|
targetHealth: intendedTargetId === '__player__' ? state.player.health : member?.health ?? null,
|
|
targetStatus: member?.status ?? null,
|
|
feedback: state.feedback,
|
|
});
|
|
};
|
|
captureInputState(after);
|
|
const code = action.key.match(/Digit[1-8]/)?.[0] ?? `Digit${action.key}`;
|
|
const expectedModifiers = { shiftKey: action.key.startsWith('Shift+'), altKey: action.key.startsWith('Alt+'), ctrlKey: false };
|
|
const inputKeyEvents = await page.evaluate(({ expectedCode, sinceAt, modifiers }) => (window.nativeRouteKeyLog ?? [])
|
|
.filter((event) => event.code === expectedCode && event.at >= sinceAt
|
|
&& event.shiftKey === modifiers.shiftKey && event.altKey === modifiers.altKey
|
|
&& event.ctrlKey === modifiers.ctrlKey).slice(-3),
|
|
{ expectedCode: code, sinceAt: since, modifiers: expectedModifiers });
|
|
const eligibleRecipientActorIds = kind !== 'healing' ? [intendedTargetId]
|
|
: action.name === 'Group Heal'
|
|
? ['__player__', ...before.party.map((member) => member.id)]
|
|
: [intendedTargetId];
|
|
const eligibleRecipientSet = new Set(eligibleRecipientActorIds);
|
|
const relevantEventsFor = (state) => state.events.filter((event) => event.occurredAt >= since
|
|
&& event.abilityId === action.id && sourcePlayerEvent(event)
|
|
&& (kind === 'damage' ? event.kind === 'damage' : event.kind === 'healing')
|
|
&& (kind === 'damage' || eligibleRecipientSet.has(event.targetActorId)));
|
|
const maxCastWaitMs = Math.min(8_000, Math.max(2_600, Number(action.castTimeMs ?? 0) + 1_800));
|
|
while (Date.now() - castWaitStarted < maxCastWaitMs) {
|
|
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);
|
|
captureInputState(after);
|
|
}
|
|
const relevantEvents = relevantEventsFor(after);
|
|
const castStartEvents = after.events.filter((event) => event.occurredAt >= since
|
|
&& event.abilityId === action.id && sourcePlayerEvent(event)
|
|
&& event.kind === 'cast' && event.outcome === 'started');
|
|
const memberBefore = before.party.find((member) => member.id === intendedTargetId);
|
|
const memberAfter = after.party.find((member) => member.id === intendedTargetId);
|
|
const cast = {
|
|
at: before.now,
|
|
actionAttempted: true,
|
|
key: action.key,
|
|
abilityId: action.id,
|
|
abilityName: action.name,
|
|
kind,
|
|
reason,
|
|
intendedTargetId,
|
|
eligibleRecipientActorIds,
|
|
selectedFriendlyActorIdBefore: before.player.selectedFriendlyActorId,
|
|
selectedFriendlyActorIdAfter: after.player.selectedFriendlyActorId,
|
|
partySelectedMemberIdBefore: before.player.partySelectedMemberId,
|
|
partySelectedMemberIdAfter: after.player.partySelectedMemberId,
|
|
partySelfSelectedBefore: before.player.partySelfSelected,
|
|
partySelfSelectedAfter: after.player.partySelfSelected,
|
|
selectedTargetIdBefore: before.player.selectedTargetId,
|
|
actionBindingAtInput: boundKeyEntry ? { bindingId: boundKeyEntry[0], abilityId: boundKeyEntry[1] } : null,
|
|
expectedBinding: boundKeyEntry ? boundKeyEntry[1] === action.id : false,
|
|
readiness: {
|
|
waitedMs: Date.now() - readyWaitStartedAt,
|
|
timedOut: readinessTimedOut,
|
|
activeCastAtInput: before.player.activeCast,
|
|
globalCooldownEndsAt: before.player.globalCooldownEndsAt,
|
|
globalCooldownRemainingMs: before.player.globalCooldownRemainingMs,
|
|
personalCooldownEndsAt: before.player.cooldowns?.[action.id] ?? 0,
|
|
personalCooldownRemainingMs: Math.max(0, (before.player.cooldowns?.[action.id] ?? 0) - before.now),
|
|
controlledUntil: before.player.controlledUntil,
|
|
controlMechanic: before.player.controlMechanic,
|
|
actionBlockedByControl: before.player.actionBlockedByControl,
|
|
actionSchool,
|
|
actionSchoolSource,
|
|
actionSchoolResolution,
|
|
schoolLockEndsAt: actionSchool ? before.player.schoolLockedUntil?.[actionSchool] ?? 0 : 0,
|
|
schoolLockRemainingMs: actionSchool
|
|
? Math.max(0, Number(before.player.schoolLockedUntil?.[actionSchool] ?? 0) - before.now) : 0,
|
|
activeSchoolLocksAtInput: activeSchoolLocks(before),
|
|
schoolLockBlockedBecauseSchoolUnknown: !actionSchool && activeSchoolLocks(before).length > 0,
|
|
focusAtInput: before.player.focusedElement,
|
|
},
|
|
feedbackImmediatelyAfterInput,
|
|
feedbackBeforeInput: before.feedback,
|
|
inputStateTrace,
|
|
inputKeyEvents,
|
|
inputKeyObserved: inputKeyEvents.length > 0,
|
|
observedCastStartEvents: castStartEvents.map((event) => ({
|
|
occurredAt: event.occurredAt, targetActorId: event.targetActorId, outcome: event.outcome,
|
|
})),
|
|
actionAccepted: castStartEvents.length > 0 || relevantEvents.length > 0,
|
|
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,
|
|
targetStatusBefore: memberBefore?.status ?? null,
|
|
targetStatusAfter: memberAfter?.status ?? null,
|
|
playerHealthBefore: before.player.health,
|
|
playerHealthAfter: after.player.health,
|
|
playerMaxHealth: before.player.maxHealth,
|
|
playerResourceBefore: before.player.resource,
|
|
playerResourceAfter: after.player.resource,
|
|
playerMaxResource: before.player.maxResource,
|
|
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,
|
|
targetActorName: event.targetActorId === '__player__' ? routeResult.profile?.name ?? null
|
|
: after.party.find((member) => member.id === event.targetActorId)?.name ?? null,
|
|
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,
|
|
feedbackAtEnd: after.feedback,
|
|
};
|
|
routeResult.actions.push(cast);
|
|
return { before, after, cast };
|
|
}
|
|
|
|
function recordHealOutcome(record, cast, targetId, targetName) {
|
|
record.heals.push(cast);
|
|
if (cast.actionResolved) return;
|
|
const outcome = {
|
|
at: cast.at,
|
|
targetId,
|
|
targetName,
|
|
abilityId: cast.abilityId,
|
|
abilityName: cast.abilityName,
|
|
key: cast.key,
|
|
attempted: cast.actionAttempted !== false,
|
|
feedbackBeforeInput: cast.feedbackBeforeInput ?? null,
|
|
feedbackImmediatelyAfterInput: cast.feedbackImmediatelyAfterInput ?? null,
|
|
feedbackAtEnd: cast.feedbackAtEnd ?? cast.feedback ?? null,
|
|
observedCastStartEvents: cast.observedCastStartEvents ?? [],
|
|
inputKeyEvents: cast.inputKeyEvents ?? [],
|
|
readiness: cast.readiness ?? null,
|
|
resourceBefore: cast.resourceBefore ?? cast.playerResourceBefore ?? null,
|
|
resourceAfter: cast.resourceAfter ?? cast.playerResourceAfter ?? null,
|
|
playerHealthBefore: cast.playerHealthBefore ?? null,
|
|
playerHealthAfter: cast.playerHealthAfter ?? null,
|
|
targetHealthBefore: cast.targetHealthBefore ?? null,
|
|
targetHealthAfter: cast.targetHealthAfter ?? null,
|
|
targetStatusBefore: cast.targetStatusBefore ?? null,
|
|
targetStatusAfter: cast.targetStatusAfter ?? null,
|
|
allyDistanceAtInput: cast.allyDistanceAtInput ?? null,
|
|
selectedFriendlyActorIdBefore: cast.selectedFriendlyActorIdBefore ?? null,
|
|
selectedFriendlyActorIdAfter: cast.selectedFriendlyActorIdAfter ?? null,
|
|
actionAccepted: cast.actionAccepted ?? false,
|
|
reason: cast.outcome ?? (cast.actionAccepted
|
|
? 'cast-start-observed-but-no-effective-heal-event'
|
|
: 'no-player-cast-start-event-observed; see feedback-and-readiness-telemetry'),
|
|
};
|
|
if (cast.actionAttempted === false) (record.skippedHealActions ??= []).push(outcome);
|
|
else record.failedHealAttempts.push(outcome);
|
|
}
|
|
|
|
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
|
|
|| !Array.isArray(guide.objectives) || guide.objectives.length === 0) {
|
|
throw new Error(`${dungeonId}: source navigation guide is absent or malformed.`);
|
|
}
|
|
return guide;
|
|
}
|
|
|
|
function navigationCheckpoints(waypoints, objectives = []) {
|
|
const objectiveByWaypoint = new Map(objectives.map((objective) => [objective.waypointIndex, objective]));
|
|
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;
|
|
const objective = objectiveByWaypoint.get(index) ?? null;
|
|
checkpoints.push({ waypointIndex: index, position,
|
|
accumulatedDistance: Number(accumulatedDistance.toFixed(2)),
|
|
reason: objective ? 'boss-objective-anchor' : 'source-navigation-vertex',
|
|
...(objective ? { objectiveId: objective.id, objectiveName: objective.name } : {}),
|
|
});
|
|
}
|
|
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);
|
|
if (before.game.overlay === 'dungeon-results') {
|
|
return { skipped: 'dungeon-results overlay is open; camera input is unavailable', screenshots };
|
|
}
|
|
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, 'campaign-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',
|
|
};
|
|
}
|
|
|
|
function dungeonRunRecordedBossKill(snapshot, bossId) {
|
|
return Boolean(snapshot.dungeonRun?.killedBossIds?.includes(bossId));
|
|
}
|
|
|
|
function findCampaignBoss(snapshot, objective) {
|
|
return snapshot.mobs.find((mob) => mob.id === objective.id && mob.boss)
|
|
?? snapshot.mobs.find((mob) => mob.name === objective.name && mob.boss)
|
|
?? null;
|
|
}
|
|
|
|
function safeImageSlug(value) {
|
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
}
|
|
|
|
async function selectCampaignBoss(page, objective, attempts = 48) {
|
|
const selections = [];
|
|
let current = await readLiveState(page);
|
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
if (current.game.overlay === 'dungeon-results' || current.game.defeatedPromptVisible || current.player.health <= 0) break;
|
|
if (current.player.selectedTargetId === findCampaignBoss(current, objective)?.id) {
|
|
return { current, selections, selected: true };
|
|
}
|
|
if (!await pressRouteKey(page, 'Tab')) break;
|
|
await page.waitForTimeout(55);
|
|
current = await readLiveState(page);
|
|
const selected = current.mobs.find((mob) => mob.id === current.player.selectedTargetId) ?? null;
|
|
selections.push({ at: current.now, selectedId: selected?.id ?? null,
|
|
selectedName: selected?.name ?? null, selectedBoss: Boolean(selected?.boss) });
|
|
}
|
|
return { current, selections, selected: current.player.selectedTargetId === findCampaignBoss(current, objective)?.id };
|
|
}
|
|
|
|
async function runCampaignBossCheckpoint(page, route, result, objective, profile, plan, collect, deadline, vowSetup) {
|
|
const record = {
|
|
bossId: objective.id, bossName: objective.name, navigationWaypointIndex: objective.waypointIndex,
|
|
reachedAt: Date.now(), arrivalPosition: (await readLiveState(page)).player.position,
|
|
sourcePosition: objective.position, commands: [], samples: [], playerCasts: [], vowAttempts: [],
|
|
damageRangeChecks: [], healRangeChecks: [],
|
|
outcome: 'in-progress',
|
|
};
|
|
result.bossEncounters.push(record);
|
|
let current = await readLiveState(page);
|
|
collect(current);
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
record.outcome = 'dungeon-results-overlay-open-before-boss-input';
|
|
return current;
|
|
}
|
|
const bossAppearedDeadline = Math.min(deadline, Date.now() + 3_000);
|
|
let boss = findCampaignBoss(current, objective);
|
|
while (!boss && Date.now() < bossAppearedDeadline) {
|
|
await page.waitForTimeout(150);
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
record.outcome = 'dungeon-results-overlay-open-before-boss-input';
|
|
return current;
|
|
}
|
|
boss = findCampaignBoss(current, objective);
|
|
}
|
|
if (!boss || boss.dead || boss.health <= 0) {
|
|
record.progressKillAlreadyRecorded = dungeonRunRecordedBossKill(current, objective.id);
|
|
record.outcome = record.progressKillAlreadyRecorded || boss?.dead || boss?.health <= 0
|
|
? 'boss-defeated-before-navigation-checkpoint' : 'boss-not-visible-at-navigation-checkpoint';
|
|
return current;
|
|
}
|
|
|
|
const selected = await selectCampaignBoss(page, objective);
|
|
current = selected.current;
|
|
collect(current);
|
|
record.targetSelection = { selected: selected.selected, attempts: selected.selections };
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
record.outcome = 'dungeon-results-overlay-open-during-target-selection';
|
|
return current;
|
|
}
|
|
if (!selected.selected) {
|
|
record.outcome = 'boss-not-selected-by-normal-tab-targeting';
|
|
return current;
|
|
}
|
|
await pressRouteKey(page, 'F1');
|
|
record.commands.push({ at: Date.now(), key: 'F1', command: 'Attack', targetId: boss.id,
|
|
targetName: boss.name, source: 'normal party command key' });
|
|
let lastDamageAt = 0;
|
|
let lastHealAt = 0;
|
|
let lastLosRecallAt = 0;
|
|
let lastSampleAt = 0;
|
|
const fightDeadline = Math.min(deadline, Date.now() + 75_000);
|
|
while (Date.now() < fightDeadline) {
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
if (stockadeVowDiagnostic && result.vowDiagnostic?.status !== 'watching') {
|
|
record.outcome = result.vowDiagnostic?.confirmedCancellation
|
|
? 'targeted-vow-diagnostic-confirmed-cancellation-stop'
|
|
: 'targeted-vow-diagnostic-ended-before-boss-input';
|
|
break;
|
|
}
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
record.outcome = 'dungeon-results-overlay-open';
|
|
record.dungeonResultOverlay = current.game.overlay;
|
|
break;
|
|
}
|
|
if (current.player.health <= 0) {
|
|
const recovery = await recoverPlayerThroughDefeatUi(page, result, collect, `boss encounter ${objective.name}`);
|
|
record.playerRecoveryActions ??= [];
|
|
if (recovery.transition) record.playerRecoveryActions.push(recovery.transition);
|
|
current = recovery.snapshot;
|
|
if (recovery.recovered) {
|
|
record.outcome = 'player-recovered-route-replay-required';
|
|
return current;
|
|
}
|
|
record.outcome = 'player-dead-during-boss-encounter';
|
|
record.playerDeathRecoveryBlocked = recovery.reason;
|
|
break;
|
|
}
|
|
boss = findCampaignBoss(current, objective);
|
|
if (!boss || boss.dead || boss.health <= 0) {
|
|
record.progressKillObserved = dungeonRunRecordedBossKill(current, objective.id);
|
|
record.outcome = record.progressKillObserved || boss?.dead || boss?.health <= 0
|
|
? 'boss-defeated-during-normal-campaign-encounter' : 'boss-removed-from-live-scene';
|
|
record.bossAtEnd = boss;
|
|
break;
|
|
}
|
|
if (lastSampleAt === 0 || current.now - lastSampleAt >= 1_000) {
|
|
record.samples.push({ at: current.now, health: boss.health, maxHealth: boss.maxHealth,
|
|
activeCast: boss.activeCast, playerHealth: current.player.health,
|
|
partyHealth: current.party.map((member) => ({ id: member.id, name: member.name,
|
|
health: member.health, maxHealth: member.maxHealth, position: member.position })) });
|
|
lastSampleAt = current.now;
|
|
}
|
|
|
|
const selectedBoss = current.player.selectedTargetId === boss.id;
|
|
if (!selectedBoss && current.now - (record.lastTargetRetryAt ?? 0) >= 1_000) {
|
|
const nextSelection = await selectCampaignBoss(page, objective, 24);
|
|
current = nextSelection.current;
|
|
collect(current);
|
|
record.targetRetries ??= [];
|
|
record.targetRetries.push({ at: current.now, selected: nextSelection.selected,
|
|
attempts: nextSelection.selections.length });
|
|
record.lastTargetRetryAt = current.now;
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
record.outcome = 'dungeon-results-overlay-open-during-target-selection';
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (stockadeVowDiagnostic && boss.name === DEFAULT_TEST_DUNGEON_BOSS
|
|
&& isHamhockChainLightning(boss)) {
|
|
const vowWatch = await attemptStockadeVowInterrupt(page, result, record, current,
|
|
plan.vowOfSilence, collect);
|
|
current = vowWatch.snapshot;
|
|
if (vowWatch.confirmed) {
|
|
record.outcome = 'targeted-vow-diagnostic-confirmed-cancellation-stop';
|
|
break;
|
|
}
|
|
if (result.vowDiagnostic.status !== 'watching') {
|
|
record.outcome = 'targeted-vow-diagnostic-ended-without-cancellation';
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
const casterCast = !stockadeVowDiagnostic && boss.name === DEFAULT_TEST_DUNGEON_BOSS
|
|
&& (boss.activeCast?.spellId === 421 || boss.activeCast?.attackId === 'spell-421'
|
|
|| /chain lightning/i.test(boss.activeCast?.name ?? ''))
|
|
? boss.activeCast : null;
|
|
if (route.id === DEFAULT_TEST_DUNGEON_ID && vowSetup?.menuAssignmentObserved && casterCast
|
|
&& casterCast.completesAt > current.now && current.player.selectedTargetId === boss.id
|
|
&& current.now - (record.lastVowAttemptAt ?? 0) > 1_000) {
|
|
const attempt = { observedAt: current.now, input: vowSetup.input, abilityId: vowSetup.id,
|
|
castBefore: casterCast, playerPosition: current.player.position,
|
|
bossPosition: boss.position,
|
|
distanceToBoss: hasFinitePosition(boss.position) ? distance3(current.player.position, boss.position) : null };
|
|
await pressRouteKey(page, vowSetup.input);
|
|
await page.waitForTimeout(100);
|
|
const afterVow = await readLiveState(page);
|
|
collect(afterVow);
|
|
const afterBoss = findCampaignBoss(afterVow, objective);
|
|
const vowEvents = afterVow.events.filter((event) => event.sourceActorId === '__player__'
|
|
&& event.abilityId === 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 < casterCast.completesAt);
|
|
attempt.playerVowEvents = vowEvents;
|
|
record.vowAttempts.push(attempt);
|
|
record.lastVowAttemptAt = current.now;
|
|
current = afterVow;
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
record.outcome = 'dungeon-results-overlay-open-after-vow-input';
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const woundedMembers = current.party.filter((member) => member.health > 0
|
|
&& member.health < member.maxHealth * 0.70 && hasFinitePosition(member.position))
|
|
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth);
|
|
const wounded = woundedMembers[0] ?? null;
|
|
const selfNeedsHealing = current.player.health < current.player.maxHealth * 0.62;
|
|
const groupHealMembers = woundedMembers.filter((member) => distance3(current.player.position, member.position) <= 120);
|
|
const groupHealCooldownEndsAt = Number(current.player.cooldowns?.[plan.groupHealing?.id] ?? 0);
|
|
const useGroupHeal = Boolean(plan.groupHealing && groupHealMembers.length >= 2 && !selfNeedsHealing
|
|
&& groupHealCooldownEndsAt <= current.now);
|
|
const isSelf = !useGroupHeal && selfNeedsHealing && (!wounded || current.player.health / current.player.maxHealth
|
|
< wounded.health / wounded.maxHealth);
|
|
const healTarget = (isSelf || useGroupHeal) ? null : wounded;
|
|
const healAction = useGroupHeal ? plan.groupHealing : plan.healing;
|
|
const healPosition = (isSelf || useGroupHeal) ? current.player.position : healTarget?.position;
|
|
const healRange = useGroupHeal ? 120 : Number(healAction?.range?.max ?? 25) - 1;
|
|
const healDistance = healPosition ? distance3(current.player.position, healPosition) : null;
|
|
if (healPosition && healAction && current.now - (record.lastHealRangeCheckAt ?? 0) > 1_500) {
|
|
record.healRangeChecks.push({ at: current.now,
|
|
targetId: (isSelf || useGroupHeal) ? '__player__' : healTarget.id,
|
|
targetName: useGroupHeal ? `Group Heal (${groupHealMembers.length} nearby injured party members)`
|
|
: isSelf ? profile.name : healTarget.name,
|
|
distance: healDistance, maxRange: healRange, withinRange: healDistance <= healRange,
|
|
recipientRanges: useGroupHeal ? groupHealMembers.map((member) => ({ id: member.id, name: member.name,
|
|
distance: distance3(current.player.position, member.position),
|
|
withinGroupSpellRange: distance3(current.player.position, member.position) <= 120 })) : undefined });
|
|
record.lastHealRangeCheckAt = current.now;
|
|
}
|
|
if (healPosition && healAction && current.now - lastHealAt > 1_200 && healDistance <= healRange) {
|
|
if (isSelf || useGroupHeal) await selectPlayerFrame(page, profile.name);
|
|
else await selectPartyMember(page, healTarget);
|
|
const heal = await performCast(page, result, healAction, 'healing', (isSelf || useGroupHeal) ? '__player__' : healTarget.id,
|
|
useGroupHeal ? `normal campaign boss group support: ${groupHealMembers.length} nearby injured party members; Group Heal is learned and UI-bound`
|
|
: isSelf ? 'normal campaign boss support: player health below 62%'
|
|
: `normal campaign boss support: ${healTarget.name} below 70%`);
|
|
record.playerCasts.push({ ...heal.cast, role: useGroupHeal ? 'group-heal' : isSelf ? 'self-heal' : 'party-heal' });
|
|
lastHealAt = Date.now();
|
|
await page.waitForTimeout(75);
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
record.outcome = 'dungeon-results-overlay-open-after-heal-input';
|
|
break;
|
|
}
|
|
const failedFeedback = [heal.cast.feedbackImmediatelyAfterInput, heal.cast.feedbackAtEnd, heal.cast.feedback]
|
|
.filter(Boolean);
|
|
const observedLosRejection = failedFeedback.some((feedback) => /not in line of sight/i.test(feedback.message ?? ''));
|
|
const recoveryTarget = (isSelf || useGroupHeal) ? null
|
|
: current.party.find((member) => member.id === healTarget.id && member.health > 0);
|
|
if (observedLosRejection && recoveryTarget && lastLosRecallAt === 0
|
|
&& current.game.overlay !== 'dungeon-results') {
|
|
const partyBeforeRecall = current.party.map((member) => ({ id: member.id, name: member.name,
|
|
distance: distance3(current.player.position, member.position) }));
|
|
await pressRouteKey(page, 'F4');
|
|
lastLosRecallAt = Date.now();
|
|
await page.waitForTimeout(180);
|
|
const recalled = await readLiveState(page);
|
|
collect(recalled);
|
|
const recallRecord = { at: lastLosRecallAt, trigger: 'explicit party-heal line-of-sight rejection',
|
|
control: 'F4 Recall party (normal UI)', targetId: recoveryTarget.id, targetName: recoveryTarget.name,
|
|
partyDistancesBefore: partyBeforeRecall,
|
|
partyDistancesAfter: recalled.party.map((member) => ({ id: member.id, name: member.name,
|
|
distance: distance3(recalled.player.position, member.position) })),
|
|
playerPosition: recalled.player.position, overlay: recalled.game.overlay };
|
|
(record.losRecoveryActions ??= []).push(recallRecord);
|
|
if (recalled.game.overlay !== 'dungeon-results') {
|
|
const retryTarget = recalled.party.find((member) => member.id === recoveryTarget.id && member.health > 0);
|
|
const retryDistance = retryTarget?.position ? distance3(recalled.player.position, retryTarget.position) : Infinity;
|
|
if (retryTarget && retryDistance <= Number(plan.healing?.range?.max ?? 25) - 1) {
|
|
await selectPartyMember(page, retryTarget);
|
|
const retry = await performCast(page, result, plan.healing, 'healing', retryTarget.id,
|
|
`normal campaign boss LOS recovery retry after F4 party recall: ${retryTarget.name}`);
|
|
record.playerCasts.push({ ...retry.cast, role: 'party-heal-los-retry' });
|
|
recallRecord.retry = { targetId: retryTarget.id,
|
|
targetHealthBefore: retry.cast.targetHealthBefore ?? null,
|
|
targetHealthAfter: retry.cast.targetHealthAfter ?? null,
|
|
selectedFriendlyActorIdBefore: retry.cast.selectedFriendlyActorIdBefore ?? null,
|
|
actionAccepted: retry.cast.actionAccepted ?? false, actionResolved: retry.cast.actionResolved,
|
|
feedbackImmediatelyAfterInput: retry.cast.feedbackImmediatelyAfterInput ?? null,
|
|
feedbackAtEnd: retry.cast.feedbackAtEnd ?? retry.cast.feedback ?? null,
|
|
readiness: retry.cast.readiness ?? null, allyDistanceAtInput: retry.cast.allyDistanceAtInput };
|
|
await page.waitForTimeout(75);
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
record.outcome = 'dungeon-results-overlay-open-after-los-recovery-retry';
|
|
break;
|
|
}
|
|
} else recallRecord.retry = { skipped: true, reason: retryTarget
|
|
? `recall left intended target outside ordinary heal range (${retryDistance.toFixed(2)}m)`
|
|
: 'intended target no longer alive after recall' };
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const latestBoss = findCampaignBoss(current, objective);
|
|
const damageRange = Number(plan.damage?.range?.max ?? 20) - 1.5;
|
|
const damageDistance = latestBoss && hasFinitePosition(latestBoss.position)
|
|
? distance3(current.player.position, latestBoss.position) : null;
|
|
const targetInRange = damageDistance !== null && damageDistance <= damageRange;
|
|
if (latestBoss && current.now - (record.lastDamageRangeCheckAt ?? 0) > 1_500) {
|
|
record.damageRangeChecks.push({ at: current.now, targetId: latestBoss.id,
|
|
distance: damageDistance, maxRange: damageRange, withinRange: targetInRange,
|
|
selectedTargetId: current.player.selectedTargetId, lineOfSight: 'normal game targeting and cast resolution' });
|
|
record.lastDamageRangeCheckAt = current.now;
|
|
}
|
|
if (plan.damage && targetInRange && current.player.selectedTargetId === latestBoss.id
|
|
&& current.now - lastDamageAt >= 1_500) {
|
|
const attack = await performCast(page, result, plan.damage, 'damage', latestBoss.id,
|
|
'normal keyboard damage against the live campaign boss; engine resolves range, targeting, and line of sight');
|
|
record.playerCasts.push(attack.cast);
|
|
lastDamageAt = Date.now();
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
record.outcome = 'dungeon-results-overlay-open-after-damage-input';
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (latestBoss && hasFinitePosition(latestBoss.position) && plan.damage
|
|
&& distance3(current.player.position, latestBoss.position) > Number(plan.damage.range?.max ?? 20) - 1.5
|
|
&& current.now - (record.lastRangeMoveAt ?? 0) > 1_200) {
|
|
const movement = await walkToward(page, result, latestBoss.position, 300);
|
|
if (movement) {
|
|
record.rangeApproachMovement ??= [];
|
|
record.rangeApproachMovement.push(movement.step);
|
|
current = movement.snapshot;
|
|
collect(current);
|
|
}
|
|
record.lastRangeMoveAt = Date.now();
|
|
}
|
|
await page.waitForTimeout(100);
|
|
}
|
|
if (record.outcome === 'in-progress') record.outcome = 'boss-fight-time-budget-ended';
|
|
record.endedAt = Date.now();
|
|
record.durationMs = record.endedAt - record.reachedAt;
|
|
record.bossAtFinalRead = findCampaignBoss(current, objective);
|
|
record.killedBossIdAtFinalRead = dungeonRunRecordedBossKill(current, objective.id);
|
|
record.dungeonResultOverlay = current.game.overlay;
|
|
record.screenshot = `${route.id}-boss-${safeImageSlug(objective.name)}.png`;
|
|
if (current.game.overlay !== 'dungeon-results') {
|
|
await page.screenshot({ path: path.join(outputDirectory, record.screenshot) });
|
|
}
|
|
return current;
|
|
}
|
|
|
|
async function replayRecoveredRouteToCursor(page, route, result, profile, plan, collect, deadline, vowSetup) {
|
|
const checkpoints = result.navigationGuidance.checkpoints;
|
|
const guidance = readNavigationGuidance(route.id);
|
|
const tolerance = 0.65;
|
|
let replay = beginPendingRouteReplay(result);
|
|
let current = await readLiveState(page);
|
|
collect(current);
|
|
let blocked = null;
|
|
|
|
while (replay && Date.now() < deadline) {
|
|
if (!replay.authoredCheckpointMatched || replay.resumeFromCheckpointIndex > replay.interruptedCheckpointIndex) {
|
|
blocked = { kind: 'recovery-route-replay-cursor-unresolved', replayId: replay.id,
|
|
resumeFromCheckpointIndex: replay.resumeFromCheckpointIndex,
|
|
interruptedCheckpointIndex: replay.interruptedCheckpointIndex };
|
|
replay.status = 'blocked-before-replay';
|
|
replay.endedAt = Date.now();
|
|
replay.blocked = blocked;
|
|
break;
|
|
}
|
|
|
|
let restartFromNewRecovery = false;
|
|
for (let checkpointIndex = replay.resumeFromCheckpointIndex;
|
|
checkpointIndex <= replay.interruptedCheckpointIndex && Date.now() < deadline;
|
|
checkpointIndex += 1) {
|
|
if (current.game.overlay === 'dungeon-results') break;
|
|
const checkpoint = checkpoints[checkpointIndex];
|
|
if (!checkpoint) {
|
|
blocked = { kind: 'recovery-route-replay-checkpoint-missing', checkpointIndex };
|
|
break;
|
|
}
|
|
result.activeNavigationCheckpointIndex = checkpointIndex;
|
|
let stepAttempts = 0;
|
|
let blockedStreak = 0;
|
|
while (distance3(current.player.position, checkpoint.position) > tolerance && Date.now() < deadline) {
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
blocked = { kind: 'dungeon-results-overlay-open-during-recovery-replay', checkpointIndex };
|
|
break;
|
|
}
|
|
if (current.player.health <= 0) {
|
|
const recovery = await recoverPlayerThroughDefeatUi(page, result, collect,
|
|
`replayed route checkpoint ${checkpointIndex}`);
|
|
current = recovery.snapshot;
|
|
if (!recovery.recovered) {
|
|
blocked = { kind: 'player-death-during-recovery-replay', checkpointIndex,
|
|
recoveryBlocked: recovery.reason };
|
|
break;
|
|
}
|
|
replay = beginPendingRouteReplay(result);
|
|
restartFromNewRecovery = true;
|
|
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) {
|
|
blocked = { kind: 'no-directional-input-during-recovery-replay', checkpointIndex,
|
|
playerPosition: current.player.position };
|
|
break;
|
|
}
|
|
current = moved.snapshot;
|
|
collect(current);
|
|
stepAttempts += 1;
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
blocked = { kind: 'dungeon-results-overlay-open-during-recovery-replay', checkpointIndex };
|
|
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,
|
|
`ordered recovery replay checkpoint ${checkpointIndex}`);
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
blocked = { kind: 'dungeon-results-overlay-open-during-recovery-pack', checkpointIndex };
|
|
break;
|
|
}
|
|
if (result.pendingRouteReplay) {
|
|
replay = beginPendingRouteReplay(result);
|
|
restartFromNewRecovery = true;
|
|
break;
|
|
}
|
|
if (current.player.health <= 0) {
|
|
blocked = { kind: 'player-death-during-recovery-pack', checkpointIndex,
|
|
recoveryBlocked: result.packEncounters.at(-1)?.playerDeathRecoveryBlocked ?? null };
|
|
break;
|
|
}
|
|
}
|
|
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) {
|
|
blocked = { kind: blockedStreak >= 5 ? 'movement-blocked-during-recovery-replay'
|
|
: 'checkpoint-timeout-during-recovery-replay', checkpointIndex, checkpoint, stepAttempts,
|
|
playerPosition: current.player.position, distanceToCheckpoint: afterDistance,
|
|
recentMovement: result.movement.slice(-5) };
|
|
break;
|
|
}
|
|
}
|
|
if (blocked || restartFromNewRecovery) break;
|
|
const verified = distance3(current.player.position, checkpoint.position) <= tolerance;
|
|
replay.checkpointVisits.push({ checkpointIndex, waypointIndex: checkpoint.waypointIndex,
|
|
observedPosition: current.player.position, verified, at: current.now,
|
|
objectiveId: checkpoint.objectiveId ?? null });
|
|
if (!verified) {
|
|
blocked = { kind: 'recovery-replay-checkpoint-unverified', checkpointIndex,
|
|
playerPosition: current.player.position, checkpoint: checkpoint.position };
|
|
break;
|
|
}
|
|
if (checkpoint.objectiveId && !dungeonRunRecordedBossKill(current, checkpoint.objectiveId)) {
|
|
const objective = guidance.objectives.find((item) => item.id === checkpoint.objectiveId);
|
|
if (!objective) {
|
|
blocked = { kind: 'recovery-replay-objective-not-in-guide', checkpointIndex,
|
|
objectiveId: checkpoint.objectiveId };
|
|
break;
|
|
}
|
|
current = await runCampaignBossCheckpoint(page, route, result, objective, profile, plan,
|
|
collect, deadline, vowSetup);
|
|
collect(current);
|
|
if (result.pendingRouteReplay) {
|
|
replay = beginPendingRouteReplay(result);
|
|
restartFromNewRecovery = true;
|
|
break;
|
|
}
|
|
if (current.game.overlay === 'dungeon-results') break;
|
|
if (current.player.health <= 0) {
|
|
blocked = { kind: 'player-death-during-recovery-replay-objective', checkpointIndex,
|
|
objectiveId: objective.id };
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (restartFromNewRecovery) continue;
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
replay.status = 'native-results-overlay-open-during-ordered-replay';
|
|
replay.endedAt = Date.now();
|
|
replay.finalPosition = current.player.position;
|
|
replay.nativeResultOverlay = current.game.overlay;
|
|
replay.progressAtEnd = current.dungeonRun;
|
|
return { replayed: false, nativeResultObserved: true, snapshot: current, replay, blocked: null };
|
|
}
|
|
if (blocked) {
|
|
replay.status = 'blocked-during-ordered-replay';
|
|
replay.endedAt = Date.now();
|
|
replay.blocked = blocked;
|
|
break;
|
|
}
|
|
const finalVisit = replay.checkpointVisits.at(-1);
|
|
if (finalVisit?.checkpointIndex !== replay.interruptedCheckpointIndex || !finalVisit.verified) {
|
|
blocked = { kind: Date.now() >= deadline ? 'campaign-deadline-before-interrupted-checkpoint'
|
|
: 'replay-ended-before-interrupted-checkpoint',
|
|
lastVerifiedCheckpointIndex: finalVisit?.checkpointIndex ?? null,
|
|
interruptedCheckpointIndex: replay.interruptedCheckpointIndex,
|
|
finalPosition: current.player.position };
|
|
replay.status = blocked.kind;
|
|
replay.endedAt = Date.now();
|
|
replay.blocked = blocked;
|
|
break;
|
|
}
|
|
replay.status = 'replayed-through-interrupted-checkpoint';
|
|
replay.endedAt = Date.now();
|
|
replay.finalPosition = current.player.position;
|
|
replay.progressAtEnd = current.dungeonRun;
|
|
return { replayed: true, snapshot: current, replay, blocked: null };
|
|
}
|
|
|
|
if (replay?.status === 'replaying-ordered-waypoints') {
|
|
replay.status = 'campaign-deadline-before-interrupted-checkpoint';
|
|
replay.endedAt = Date.now();
|
|
replay.finalPosition = current.player.position;
|
|
}
|
|
return { replayed: false, snapshot: current, replay, blocked: blocked ?? { kind: 'route-replay-deadline' } };
|
|
}
|
|
|
|
async function runCampaignBossRoute(page, route, entryState, profile) {
|
|
const guidance = readNavigationGuidance(route.id);
|
|
const vowSetup = route.id === DEFAULT_TEST_DUNGEON_ID ? await assignVowThroughAbilityControls(page) : null;
|
|
if (route.id === DEFAULT_TEST_DUNGEON_ID && vowSetup?.menuAssignmentObserved) entryState = await readLiveState(page, true);
|
|
const plan = actionPlanFromSnapshot(entryState);
|
|
const hamhockObjective = guidance.objectives.find((objective) => objective.name === DEFAULT_TEST_DUNGEON_BOSS);
|
|
const result = {
|
|
dungeonId: route.id, title: route.title,
|
|
coverage: stockadeVowDiagnostic
|
|
? 'targeted normal-campaign Vow interrupt diagnostic; route inputs stop on confirmed cancellation or target defeat; not a full-clear attempt'
|
|
: `normal campaign full-route attempt across ${guidance.objectives.length} ordered boss anchors; 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 },
|
|
objectives: guidance.objectives,
|
|
navigationGuidance: {
|
|
status: guidance.status, expectedNavigationDistance: guidance.expectedNavigationDistance,
|
|
waypointCount: guidance.waypointCount, startSnapDistance: guidance.startSnapDistance,
|
|
objectiveSnapDistance: guidance.objectiveSnapDistance,
|
|
checkpoints: navigationCheckpoints(guidance.waypoints, guidance.objectives),
|
|
livePositionMutationUsed: false,
|
|
},
|
|
playerAtEntry: entryState.player, partyAtEntry: entryState.party,
|
|
dungeonRunStoreFoundAtEntry: entryState.dungeonRunStoreFound,
|
|
nativeStoreDiscoveryAtEntry: entryState.nativeStoreDiscovery,
|
|
dungeonRunProgressObserved: [],
|
|
dungeonEntry: entryState.game, actionPlan: plan,
|
|
vowSetup,
|
|
vowDiagnostic: stockadeVowDiagnostic ? {
|
|
status: vowSetup?.menuAssignmentObserved && plan.vowOfSilence
|
|
? 'watching' : 'vow-not-learned-or-not-bound-through-normal-ability-controls',
|
|
targetBossId: hamhockObjective?.id ?? null,
|
|
targetBossName: hamhockObjective?.name ?? DEFAULT_TEST_DUNGEON_BOSS,
|
|
abilityId: vowSetup?.id ?? null,
|
|
abilityName: vowSetup?.name ?? 'Vow of Silence',
|
|
input: vowSetup?.input ?? plan.vowOfSilence?.key ?? null,
|
|
bindingId: vowSetup?.boundLayerSlot ?? null,
|
|
startedAt: Date.now(),
|
|
deadlineMs: maxCampaignDurationMs,
|
|
observedCastKeys: [],
|
|
observedWindows: [],
|
|
attempts: [],
|
|
confirmedCancellation: null,
|
|
hamhockObserved: false,
|
|
targetDefeatedAt: null,
|
|
} : null,
|
|
startingPosition: entryState.player.position,
|
|
movement: [], targeting: [], actions: [], samples: [], observedCombatEvents: [],
|
|
playerDeathTransitions: [], playerRecoveryActions: [], routeReplays: [],
|
|
packEncounters: [], bossEncounters: [],
|
|
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`,
|
|
playerPartyAppearance: `${route.id}-player-equipped-silhouette-native-party.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 (stockadeVowDiagnostic && result.vowDiagnostic.status !== 'watching') {
|
|
throw new Error(`Targeted Vow diagnostic cannot start: ${result.vowDiagnostic.status}.`);
|
|
}
|
|
if (!plan.damage) throw new Error(`${route.id}: no normal bound damage ability for campaign route.`);
|
|
if (route.id === DEFAULT_TEST_DUNGEON_ID && !result.vowSetup?.menuAssignmentObserved) {
|
|
result.vowSetup?.learned || console.warn('Vow of Silence was not observed in the selected identity action catalog.');
|
|
}
|
|
if (cameraDiagnostic) {
|
|
result.cameraInputAtEntrance = await captureNormalCameraInputProbe(page, outputDirectory,
|
|
`${route.id}-entrance-camera`);
|
|
}
|
|
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();
|
|
const partyCasualties = [];
|
|
const lastPartyHealth = new Map(entryState.party.map((member) => [member.id, member.health]));
|
|
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);
|
|
if (result.vowDiagnostic?.status === 'watching') {
|
|
const targetDead = snapshot.mobs.some((mob) => mob.id === result.vowDiagnostic.targetBossId
|
|
&& (mob.dead || mob.health <= 0))
|
|
|| snapshot.dungeonRun?.killedBossIds?.includes(result.vowDiagnostic.targetBossId);
|
|
if (targetDead) {
|
|
result.vowDiagnostic.status = 'target-defeated-without-confirmed-cancellation';
|
|
result.vowDiagnostic.targetDefeatedAt = snapshot.now;
|
|
result.vowDiagnostic.stopReason = 'Hamhock was defeated before a Vow cancellation was confirmed';
|
|
} else if (snapshot.mobs.some((mob) => mob.id === result.vowDiagnostic.targetBossId
|
|
|| (mob.name === DEFAULT_TEST_DUNGEON_BOSS && mob.boss))) {
|
|
result.vowDiagnostic.hamhockObserved = true;
|
|
}
|
|
}
|
|
for (const member of snapshot.party) {
|
|
const previousHealth = lastPartyHealth.get(member.id);
|
|
if (member.health <= 0 && previousHealth > 0) {
|
|
partyCasualties.push({ id: member.id, name: member.name, observedAt: snapshot.now,
|
|
positionAtDeath: member.position, healthAtDeath: member.health, recoveredAt: null,
|
|
healthAtRecovery: null });
|
|
} else if (member.health > 0 && previousHealth <= 0) {
|
|
const casualty = [...partyCasualties].reverse().find((entry) => entry.id === member.id && !entry.recoveredAt);
|
|
if (casualty) {
|
|
casualty.recoveredAt = snapshot.now;
|
|
casualty.healthAtRecovery = member.health;
|
|
}
|
|
}
|
|
lastPartyHealth.set(member.id, member.health);
|
|
}
|
|
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;
|
|
if (result.vowDiagnostic) result.vowDiagnostic.deadlineAt = deadline;
|
|
const checkpoints = result.navigationGuidance.checkpoints;
|
|
const checkpointTolerance = 0.65;
|
|
result.initialEnemyCount = liveMobs(entryState).length;
|
|
const firstTarget = await selectHostileWithTab(page, result, 8);
|
|
if (firstTarget && firstTarget.snapshot.game.overlay !== 'dungeon-results') {
|
|
await pressRouteKey(page, 'F1');
|
|
result.partyCommand = { at: Date.now(), key: 'F1', command: 'Attack', selectedTarget: firstTarget.target.name };
|
|
await page.waitForTimeout(300);
|
|
const afterAttack = await readLiveState(page);
|
|
if (afterAttack.game.overlay !== 'dungeon-results') {
|
|
await pressRouteKey(page, '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
|
|
&& (!stockadeVowDiagnostic || result.vowDiagnostic.status === 'watching'); checkpointIndex += 1) {
|
|
result.activeNavigationCheckpointIndex = checkpointIndex;
|
|
const checkpoint = checkpoints[checkpointIndex];
|
|
let blockedStreak = 0;
|
|
let stepAttempts = 0;
|
|
while (Date.now() < deadline && distance3(current.player.position, checkpoint.position) > checkpointTolerance
|
|
&& (!stockadeVowDiagnostic || result.vowDiagnostic.status === 'watching')) {
|
|
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) {
|
|
const recovery = await recoverPlayerThroughDefeatUi(page, result, collect, `route checkpoint ${checkpointIndex}`);
|
|
current = recovery.snapshot;
|
|
if (!recovery.recovered) {
|
|
routeBlocked = { kind: 'player-death', checkpointIndex, position: current.player.position,
|
|
recoveryBlocked: recovery.reason };
|
|
break;
|
|
}
|
|
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);
|
|
if (stockadeVowDiagnostic && result.vowDiagnostic.status !== 'watching') {
|
|
routeBlocked = { kind: 'targeted-vow-diagnostic-stop', reason: result.vowDiagnostic.stopReason,
|
|
status: result.vowDiagnostic.status, checkpointIndex };
|
|
break;
|
|
}
|
|
movedSteps += 1;
|
|
stepAttempts += 1;
|
|
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;
|
|
}
|
|
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 (stockadeVowDiagnostic && result.vowDiagnostic.status !== 'watching') {
|
|
routeBlocked = { kind: 'targeted-vow-diagnostic-stop', reason: result.vowDiagnostic.stopReason,
|
|
status: result.vowDiagnostic.status, checkpointIndex };
|
|
break;
|
|
}
|
|
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),
|
|
recoveryBlocked: result.packEncounters.at(-1)?.playerDeathRecoveryBlocked ?? null };
|
|
break;
|
|
}
|
|
if (result.pendingRouteReplay) 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;
|
|
}
|
|
}
|
|
if (result.pendingRouteReplay) {
|
|
const replay = beginPendingRouteReplay(result);
|
|
if (!replay || !replay.authoredCheckpointMatched
|
|
|| replay.resumeFromCheckpointIndex > replay.interruptedCheckpointIndex) {
|
|
routeBlocked = { kind: 'player-recovery-route-replay-cursor-unresolved', checkpointIndex,
|
|
replay: replay ?? result.pendingRouteReplay };
|
|
if (replay) { replay.status = 'blocked-before-replay'; replay.endedAt = Date.now(); }
|
|
break;
|
|
}
|
|
checkpointIndex = replay.resumeFromCheckpointIndex - 1;
|
|
continue;
|
|
}
|
|
const activeReplay = result.routeReplays.at(-1)?.status === 'replaying-ordered-waypoints'
|
|
? result.routeReplays.at(-1) : null;
|
|
const checkpointVerified = !routeBlocked
|
|
&& distance3(current.player.position, checkpoint.position) <= checkpointTolerance;
|
|
if (activeReplay && checkpointIndex >= activeReplay.resumeFromCheckpointIndex
|
|
&& checkpointIndex <= activeReplay.interruptedCheckpointIndex) {
|
|
activeReplay.checkpointVisits.push({ checkpointIndex, waypointIndex: checkpoint.waypointIndex,
|
|
observedPosition: current.player.position, verified: checkpointVerified, at: current.now,
|
|
objectiveId: checkpoint.objectiveId ?? null });
|
|
} else {
|
|
result.navigationGuidance.checkpoints[checkpointIndex].observedPosition = current.player.position;
|
|
result.navigationGuidance.checkpoints[checkpointIndex].verified = checkpointVerified;
|
|
}
|
|
if (!routeBlocked && current.game.overlay !== 'dungeon-results' && checkpoint.objectiveId
|
|
&& (!stockadeVowDiagnostic || result.vowDiagnostic.status === 'watching')) {
|
|
const objective = guidance.objectives.find((item) => item.id === checkpoint.objectiveId);
|
|
if (!objective) throw new Error(`${route.id}: checkpoint refers to unknown boss ${checkpoint.objectiveId}.`);
|
|
current = await runCampaignBossCheckpoint(page, route, result, objective, profile, plan, collect, deadline, vowSetup);
|
|
collect(current);
|
|
if (stockadeVowDiagnostic && result.vowDiagnostic.status !== 'watching') {
|
|
routeBlocked = { kind: 'targeted-vow-diagnostic-stop', reason: result.vowDiagnostic.stopReason,
|
|
status: result.vowDiagnostic.status, checkpointIndex, bossId: objective.id };
|
|
}
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
routeBlocked = { kind: 'dungeon-results-overlay-open-during-boss-checkpoint', checkpointIndex,
|
|
bossId: objective.id, bossName: objective.name,
|
|
note: 'Native result overlay ended boss-route inputs; result evidence is read from the visible UI.' };
|
|
} else if (current.player.health <= 0) {
|
|
const recovery = await recoverPlayerThroughDefeatUi(page, result, collect,
|
|
`boss checkpoint ${objective.name}`);
|
|
current = recovery.snapshot;
|
|
if (!recovery.recovered) {
|
|
routeBlocked = { kind: 'player-death-during-boss-checkpoint', checkpointIndex,
|
|
bossId: objective.id, bossName: objective.name, position: current.player.position,
|
|
recoveryBlocked: recovery.reason };
|
|
}
|
|
}
|
|
}
|
|
if (result.pendingRouteReplay) {
|
|
const replay = beginPendingRouteReplay(result);
|
|
if (!replay || !replay.authoredCheckpointMatched
|
|
|| replay.resumeFromCheckpointIndex > replay.interruptedCheckpointIndex) {
|
|
routeBlocked = { kind: 'player-recovery-route-replay-cursor-unresolved', checkpointIndex,
|
|
replay: replay ?? result.pendingRouteReplay };
|
|
if (replay) { replay.status = 'blocked-before-replay'; replay.endedAt = Date.now(); }
|
|
break;
|
|
}
|
|
checkpointIndex = replay.resumeFromCheckpointIndex - 1;
|
|
continue;
|
|
}
|
|
const replayInProgress = result.routeReplays.at(-1)?.status === 'replaying-ordered-waypoints'
|
|
? result.routeReplays.at(-1) : null;
|
|
if (replayInProgress && checkpointIndex >= replayInProgress.interruptedCheckpointIndex) {
|
|
replayInProgress.status = 'replayed-through-interrupted-checkpoint';
|
|
replayInProgress.endedAt = Date.now();
|
|
replayInProgress.finalPosition = current.player.position;
|
|
replayInProgress.progressAtEnd = current.dungeonRun;
|
|
}
|
|
if (replayInProgress && routeBlocked) {
|
|
replayInProgress.status = 'blocked-during-ordered-replay';
|
|
replayInProgress.endedAt = Date.now();
|
|
replayInProgress.blocked = routeBlocked;
|
|
}
|
|
if (routeBlocked) break;
|
|
}
|
|
const unfinishedReplay = result.routeReplays.at(-1);
|
|
if (unfinishedReplay?.status === 'replaying-ordered-waypoints') {
|
|
unfinishedReplay.status = Date.now() >= deadline
|
|
? 'campaign-deadline-before-interrupted-checkpoint'
|
|
: 'route-loop-ended-before-interrupted-checkpoint';
|
|
unfinishedReplay.endedAt = Date.now();
|
|
unfinishedReplay.finalPosition = current.player.position;
|
|
}
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
if (stockadeVowDiagnostic && result.vowDiagnostic.status === 'watching') {
|
|
result.vowDiagnostic.status = current.game.overlay === 'dungeon-results'
|
|
? 'native-results-overlay-before-confirmed-cancellation'
|
|
: Date.now() >= deadline ? 'bounded-deadline-expired-without-confirmed-cancellation'
|
|
: routeBlocked ? 'route-blocked-before-confirmed-cancellation'
|
|
: 'route-ended-before-confirmed-cancellation';
|
|
result.vowDiagnostic.stopReason = routeBlocked?.kind ?? result.vowDiagnostic.status;
|
|
result.vowDiagnostic.completedAt = current.now;
|
|
}
|
|
if (stockadeVowDiagnostic && !routeBlocked) {
|
|
routeBlocked = { kind: 'targeted-vow-diagnostic-stop', status: result.vowDiagnostic.status,
|
|
reason: result.vowDiagnostic.stopReason ?? result.vowDiagnostic.status };
|
|
}
|
|
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 (route.id !== DEFAULT_TEST_DUNGEON_ID && current.game.overlay !== 'dungeon-results'
|
|
&& bossDistance <= 18 && !liveBoss) {
|
|
for (let packIndex = 0; packIndex < 5 && Date.now() < deadline && !liveBoss
|
|
&& current.game.overlay !== 'dungeon-results' && !routeBlocked; 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 pressRouteKey(page, '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 pressRouteKey(page, 'F1');
|
|
await page.waitForTimeout(300);
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
approachGuardPacks.push({ index: packIndex, outcome: 'dungeon-results-overlay-open-after-attack-command',
|
|
nearbyMobs: localMobs.map((mob) => mob.name) });
|
|
break;
|
|
}
|
|
await pressRouteKey(page, 'F2');
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
approachGuardPacks.push({ index: packIndex, outcome: 'dungeon-results-overlay-open-after-defend-command',
|
|
nearbyMobs: localMobs.map((mob) => mob.name) });
|
|
break;
|
|
}
|
|
const stabilized = await stabilizeCampaignPack(page, result, current, profile, plan, collect, deadline,
|
|
`${guidance.objective} source boss approach pack ${packIndex + 1}`);
|
|
current = stabilized;
|
|
const pack = result.packEncounters.at(-1);
|
|
if (stockadeVowDiagnostic && result.vowDiagnostic.status !== 'watching') {
|
|
routeBlocked = { kind: 'targeted-vow-diagnostic-stop', status: result.vowDiagnostic.status,
|
|
reason: result.vowDiagnostic.stopReason ?? result.vowDiagnostic.status };
|
|
}
|
|
let routeReplay = null;
|
|
if (result.pendingRouteReplay) {
|
|
routeReplay = await replayRecoveredRouteToCursor(page, route, result, profile, plan,
|
|
collect, deadline, vowSetup);
|
|
current = routeReplay.snapshot;
|
|
if (!routeReplay.replayed && !routeReplay.nativeResultObserved) {
|
|
routeBlocked = { kind: 'route-replay-blocked-after-approach-pack-recovery',
|
|
replayId: routeReplay.replay?.id ?? null, blocked: routeReplay.blocked };
|
|
}
|
|
}
|
|
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,
|
|
routeReplay: routeReplay ? { replayed: routeReplay.replayed,
|
|
nativeResultObserved: routeReplay.nativeResultObserved ?? false,
|
|
status: routeReplay.replay?.status ?? null, blocked: routeReplay.blocked } : null });
|
|
if (routeBlocked || current.game.overlay === 'dungeon-results') {
|
|
break;
|
|
}
|
|
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 && !routeBlocked && liveBoss && current.game.overlay !== 'dungeon-results') {
|
|
const selectionAttempts = [];
|
|
let bossTargetState = current;
|
|
for (let attempt = 0; attempt < 40 && Date.now() < deadline; attempt += 1) {
|
|
if (bossTargetState.player.selectedTargetId === liveBoss.id) break;
|
|
await pressRouteKey(page, '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 (bossTargetState.game.overlay !== 'dungeon-results' && result.bossTargetSelection.selected) {
|
|
await pressRouteKey(page, '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);
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
result.bossFight.outcome = 'dungeon-results-overlay-open';
|
|
break;
|
|
}
|
|
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) {
|
|
const recovery = await recoverPlayerThroughDefeatUi(page, result, collect, `final boss ${liveBoss.name}`);
|
|
current = recovery.snapshot;
|
|
if (recovery.recovered) {
|
|
result.bossFight.outcome = 'player-recovered-route-replay-in-progress';
|
|
const replay = await replayRecoveredRouteToCursor(page, route, result, profile, plan,
|
|
collect, deadline, vowSetup);
|
|
current = replay.snapshot;
|
|
result.bossFight.routeReplay = { replayed: replay.replayed, status: replay.replay?.status ?? null,
|
|
replayId: replay.replay?.id ?? null, nativeResultObserved: replay.nativeResultObserved ?? false,
|
|
blocked: replay.blocked };
|
|
if (replay.replayed) {
|
|
liveBoss = findCampaignBoss(current, { id: liveBoss.id, name: liveBoss.name }) ?? liveBoss;
|
|
continue;
|
|
}
|
|
if (replay.nativeResultObserved) {
|
|
result.bossFight.outcome = 'dungeon-results-overlay-open-after-player-recovery';
|
|
break;
|
|
}
|
|
result.bossFight.outcome = 'player-recovered-but-route-replay-incomplete';
|
|
result.bossFight.playerDeathRecoveryBlocked = replay.blocked;
|
|
break;
|
|
}
|
|
result.bossFight.outcome = 'player-dead';
|
|
result.bossFight.playerDeathRecoveryBlocked = recovery.reason;
|
|
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 pressRouteKey(page, '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 pressRouteKey(page, 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 campaign boss 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 pressRouteKey(page, '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),
|
|
observedAt: current.now,
|
|
};
|
|
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' && !current.game.defeatedPromptVisible) {
|
|
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: current.game.overlay === 'dungeon-results'
|
|
? 'dungeon-results overlay is open; camera input is unavailable'
|
|
: 'Defeated prompt is open; camera input is unavailable' };
|
|
}
|
|
result.endingPosition = current.player.position;
|
|
result.segmentEndedAt = current.now;
|
|
result.observedCampaignEvidenceSpanMs = Math.max(0, current.now - result.segmentStartedAt);
|
|
result.endingDistanceTravelled = current.game.distanceTravelled;
|
|
result.partyAtEndpoint = current.party;
|
|
result.playerAtEndpoint = current.player;
|
|
result.sceneMobsAtEndpoint = current.mobs;
|
|
result.observedDeadMobs = [...observedDeadMobs.values()];
|
|
result.partyCasualties = partyCasualties;
|
|
result.partyRecoveryUiActions = result.packEncounters.flatMap((encounter) =>
|
|
(encounter.resurrectionActions ?? []).map((action) => ({ packLabel: encounter.label, ...action })));
|
|
result.playerRecoveryUiActions = result.playerRecoveryActions ?? [];
|
|
result.finalPartySurvivors = current.party.filter((member) => member.health > 0)
|
|
.map((member) => ({ id: member.id, name: member.name, health: member.health,
|
|
maxHealth: member.maxHealth, position: member.position }));
|
|
result.finalPartyDeaths = current.party.filter((member) => member.health <= 0)
|
|
.map((member) => ({ id: member.id, name: member.name, health: member.health, position: member.position }));
|
|
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 playerHealingEvents = result.observedCombatEvents.filter((event) => event.kind === 'healing'
|
|
&& event.sourceActorId === '__player__');
|
|
const actorNameById = new Map([['__player__', profile.name],
|
|
...entryState.party.map((member) => [member.id, member.name]),
|
|
...current.party.map((member) => [member.id, member.name]),
|
|
...entryState.mobs.map((mob) => [mob.id, mob.name]),
|
|
...current.mobs.map((mob) => [mob.id, mob.name])]);
|
|
const abilityNameById = new Map(result.actionPlan.bound.map((ability) => [ability.id, ability.name]));
|
|
if (result.actionPlan.groupHealing) abilityNameById.set(result.actionPlan.groupHealing.id, result.actionPlan.groupHealing.name);
|
|
if (result.actionPlan.healing) abilityNameById.set(result.actionPlan.healing.id, result.actionPlan.healing.name);
|
|
result.playerHealingEventSummary = {
|
|
definition: 'source-attributed combat healing events; independent of harness input-window counts',
|
|
sourceActorId: '__player__',
|
|
sourceActorName: profile.name,
|
|
eventCount: playerHealingEvents.length,
|
|
effectiveEventCount: playerHealingEvents.filter((event) => event.effectiveAmount > 0).length,
|
|
effectiveAmount: playerHealingEvents.reduce((sum, event) => sum + Number(event.effectiveAmount ?? 0), 0),
|
|
overhealAmount: playerHealingEvents.reduce((sum, event) => sum + Number(event.overheal ?? 0), 0),
|
|
bySourceAbilityAndTarget: [...playerHealingEvents.reduce((groups, event) => {
|
|
const key = [event.abilityId ?? 'unknown-ability', event.targetActorId ?? 'unknown-target'].join('|');
|
|
const group = groups.get(key) ?? {
|
|
sourceActorId: event.sourceActorId,
|
|
sourceActorName: actorNameById.get(event.sourceActorId) ?? profile.name,
|
|
targetActorId: event.targetActorId ?? null,
|
|
targetName: actorNameById.get(event.targetActorId) ?? null,
|
|
abilityId: event.abilityId ?? null,
|
|
abilityName: abilityNameById.get(event.abilityId) ?? null,
|
|
eventCount: 0,
|
|
effectiveEventCount: 0,
|
|
effectiveAmount: 0,
|
|
overhealAmount: 0,
|
|
};
|
|
group.eventCount += 1;
|
|
if (Number(event.effectiveAmount ?? 0) > 0) group.effectiveEventCount += 1;
|
|
group.effectiveAmount += Number(event.effectiveAmount ?? 0);
|
|
group.overhealAmount += Number(event.overheal ?? 0);
|
|
groups.set(key, group);
|
|
return groups;
|
|
}, new Map()).values()].sort((left, right) => String(left.abilityId ?? '').localeCompare(String(right.abilityId ?? ''))
|
|
|| String(left.targetActorId ?? '').localeCompare(String(right.targetActorId ?? ''))),
|
|
};
|
|
const inputWindows = new Map();
|
|
for (const cast of [...result.actions.filter((action) => action.kind === 'healing'),
|
|
...result.packEncounters.flatMap((encounter) => encounter.heals ?? [])]) {
|
|
const key = [cast.at, cast.abilityId, cast.intendedTargetId, cast.reason].join('|');
|
|
if (!inputWindows.has(key)) inputWindows.set(key, cast);
|
|
}
|
|
const uniqueHealInputWindows = [...inputWindows.values()];
|
|
result.healingInputWindowSummary = {
|
|
definition: 'distinct scripted ordinary-key input windows; cast-start acceptance/effective events are tracked separately',
|
|
windowCount: uniqueHealInputWindows.length,
|
|
attemptedInputCount: uniqueHealInputWindows.filter((cast) => cast.actionAttempted !== false).length,
|
|
acceptedCastStartCount: uniqueHealInputWindows.filter((cast) => cast.actionAccepted === true).length,
|
|
effectiveWindowCount: uniqueHealInputWindows.filter((cast) => cast.actionResolved).length,
|
|
noCastStartInputCount: uniqueHealInputWindows.filter((cast) => cast.actionAttempted !== false
|
|
&& cast.actionAccepted === false).length,
|
|
skippedWithoutInputCount: uniqueHealInputWindows.filter((cast) => cast.actionAttempted === false).length,
|
|
effectiveAmountObservedWithinWindows: uniqueHealInputWindows.reduce((sum, cast) => sum
|
|
+ (cast.resolvedEvents ?? []).reduce((eventsSum, event) => eventsSum + Number(event.effectiveAmount ?? 0), 0), 0),
|
|
byAbility: [...uniqueHealInputWindows.reduce((groups, cast) => {
|
|
const group = groups.get(cast.abilityId) ?? {
|
|
abilityId: cast.abilityId, abilityName: cast.abilityName, windowCount: 0,
|
|
attemptedInputCount: 0, acceptedCastStartCount: 0, effectiveWindowCount: 0,
|
|
noCastStartInputCount: 0, skippedWithoutInputCount: 0, effectiveAmountObservedWithinWindows: 0,
|
|
};
|
|
group.windowCount += 1;
|
|
if (cast.actionAttempted !== false) group.attemptedInputCount += 1;
|
|
if (cast.actionAccepted === true) group.acceptedCastStartCount += 1;
|
|
if (cast.actionResolved) group.effectiveWindowCount += 1;
|
|
if (cast.actionAttempted !== false && cast.actionAccepted === false) group.noCastStartInputCount += 1;
|
|
if (cast.actionAttempted === false) group.skippedWithoutInputCount += 1;
|
|
group.effectiveAmountObservedWithinWindows += (cast.resolvedEvents ?? [])
|
|
.reduce((sum, event) => sum + Number(event.effectiveAmount ?? 0), 0);
|
|
groups.set(cast.abilityId, group);
|
|
return groups;
|
|
}, new Map()).values()],
|
|
};
|
|
const objectiveMob = current.mobs.find((mob) => mob.id === guidance.objectiveId || 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 = guidance.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,
|
|
};
|
|
const progressKilledBossIds = new Set(result.dungeonRunProgressObserved.flatMap((progress) => progress.killedBossIds ?? []));
|
|
result.bossCoverage = guidance.objectives.map((objective) => {
|
|
const checkpoint = result.bossEncounters.find((entry) => entry.bossId === objective.id) ?? null;
|
|
const deadMobObserved = observedDeadMobs.get(objective.id) === objective.name;
|
|
const killIdObserved = progressKilledBossIds.has(objective.id);
|
|
return {
|
|
id: objective.id,
|
|
name: objective.name,
|
|
naturalKillObservedBeforeOrDuringCheckpoint: Boolean(checkpoint
|
|
&& checkpoint.outcome === 'boss-defeated-before-navigation-checkpoint'),
|
|
deadMobStateObserved: deadMobObserved,
|
|
killedBossIdObserved: killIdObserved,
|
|
checkpointOutcome: checkpoint?.outcome ?? 'navigation-checkpoint-not-reached',
|
|
completionObserved: deadMobObserved || killIdObserved,
|
|
};
|
|
});
|
|
const completedBossCount = result.bossCoverage.filter((boss) => boss.completionObserved).length;
|
|
const missingRequiredBossKillIds = guidance.objectives
|
|
.filter((objective) => !progressKilledBossIds.has(objective.id)).map((objective) => objective.id);
|
|
const allRequiredBossKillIdsObserved = missingRequiredBossKillIds.length === 0;
|
|
result.dungeonResult.requiredKilledBossIdsComplete = allRequiredBossKillIdsObserved;
|
|
result.dungeonResult.missingRequiredKilledBossIds = missingRequiredBossKillIds;
|
|
result.routeBossOutcome = objectiveRunCompletionObserved ? 'final-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 = result.bossEncounters.some((encounter) => encounter.targetSelection?.selected)
|
|
|| Boolean(result.bossFight?.startedAt);
|
|
result.bossProgress = {
|
|
requiredBossCount: guidance.objectives.length,
|
|
completedBossCount,
|
|
orderedBossIds: guidance.objectives.map((objective) => objective.id),
|
|
latestKilledBossIds: [...(current.dungeonRun?.killedBossIds ?? [])],
|
|
};
|
|
result.fullClearStatus = result.dungeonResult.fullClearVisible && allRequiredBossKillIdsObserved
|
|
? 'native-full-dungeon-clear-and-all-required-killed-boss-ids-observed'
|
|
: result.dungeonResult.fullClearVisible
|
|
? 'native-results-overlay-visible; required-killed-boss-ids-incomplete; full-clear-not-credited'
|
|
: completedBossCount === guidance.objectives.length
|
|
? 'all-source-bosses-observed-defeated; full-clear-overlay-not-observed'
|
|
: completedBossCount > 0
|
|
? `${completedBossCount}-of-${guidance.objectives.length}-source-bosses-observed-defeated; full-clear-unverified`
|
|
: result.bossFight?.outcome ?? 'navigation-ended-before-boss';
|
|
if (stockadeVowDiagnostic) {
|
|
result.fullClearStatus = result.vowDiagnostic.confirmedCancellation
|
|
? 'targeted-vow-diagnostic-stopped-after-confirmed-cancellation; full-clear-not-attempted'
|
|
: 'targeted-vow-diagnostic-ended-without-confirmed-cancellation; full-clear-not-attempted';
|
|
}
|
|
result.pageErrors = [];
|
|
result.consoleErrors = [];
|
|
result.frozenBuildAssetUrls = moduleUrls.map((url) => new URL(url, page.url()).href);
|
|
result.actualAssetUrls = await page.evaluate(() => [...new Set(performance.getEntriesByType('resource')
|
|
.map((entry) => {
|
|
try { return new URL(entry.name, location.href); } catch { return null; }
|
|
})
|
|
.filter((url) => url?.pathname.startsWith('/assets/'))
|
|
.map((url) => url.href))].sort());
|
|
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 current = await readLiveState(page);
|
|
if (current.game.overlay === 'dungeon-results' || current.game.defeatedPromptVisible || current.player.health <= 0) return false;
|
|
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) {
|
|
const current = await readLiveState(page);
|
|
if (current.game.overlay === 'dungeon-results' || current.game.defeatedPromptVisible || current.player.health <= 0) return false;
|
|
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: [], failedHealAttempts: [], skippedHealActions: [], samples: [], damage: [], resurrectionActions: [],
|
|
outcome: 'in-progress',
|
|
};
|
|
result.packEncounters.push(record);
|
|
let current = snapshot;
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
record.outcome = 'dungeon-results-overlay-open';
|
|
record.dungeonResultOverlay = current.game.overlay;
|
|
record.endedAt = Date.now();
|
|
record.durationMs = record.endedAt - startedAt;
|
|
record.playerAtEnd = current.player;
|
|
record.partyAtEnd = current.party;
|
|
record.liveEngagedMobsAtEnd = [];
|
|
record.partyMaxSeparationAtEnd = routePartySeparation(current);
|
|
return current;
|
|
}
|
|
if (initialPack.length === 0) {
|
|
record.outcome = 'no-engaged-mobs-observed; clear-not-credited';
|
|
record.endedAt = Date.now();
|
|
record.durationMs = record.endedAt - startedAt;
|
|
record.playerAtEnd = current.player;
|
|
record.partyAtEnd = current.party;
|
|
record.liveEngagedMobsAtEnd = [];
|
|
record.partyMaxSeparationAtEnd = routePartySeparation(current);
|
|
return current;
|
|
}
|
|
let lastDamageAt = 0;
|
|
let lastSampleAt = 0;
|
|
let clearedSince = null;
|
|
let lastResurrectionAt = 0;
|
|
let lastLosRecallAt = 0;
|
|
const maxEncounterEnd = Math.min(deadline, Date.now() + 110_000);
|
|
await pressRouteKey(page, '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 (stockadeVowDiagnostic && result.vowDiagnostic?.status !== 'watching') {
|
|
record.outcome = result.vowDiagnostic?.confirmedCancellation
|
|
? 'targeted-vow-diagnostic-confirmed-cancellation-stop'
|
|
: 'targeted-vow-diagnostic-ended-without-cancellation';
|
|
break;
|
|
}
|
|
if (stockadeVowDiagnostic && plan.vowOfSilence && result.vowDiagnostic?.status === 'watching') {
|
|
const vowWatch = await attemptStockadeVowInterrupt(page, result, record, current,
|
|
plan.vowOfSilence, collect);
|
|
current = vowWatch.snapshot;
|
|
if (vowWatch.confirmed) {
|
|
record.outcome = 'targeted-vow-diagnostic-confirmed-cancellation-stop';
|
|
break;
|
|
}
|
|
if (result.vowDiagnostic.status !== 'watching') {
|
|
record.outcome = 'targeted-vow-diagnostic-ended-without-cancellation';
|
|
break;
|
|
}
|
|
if (vowWatch.attempted) continue;
|
|
}
|
|
if (lastSampleAt === 0 || current.now - lastSampleAt >= 1_000) {
|
|
record.samples.push({ at: current.now, player: current.player,
|
|
party: current.party, mobs: current.mobs.filter((mob) => !mob.dead && !mob.despawned).map((mob) => ({
|
|
id: mob.id, name: mob.name, health: mob.health, maxHealth: mob.maxHealth, engaged: mob.engaged,
|
|
activeCast: mob.activeCast, position: mob.position,
|
|
})) });
|
|
lastSampleAt = current.now;
|
|
}
|
|
if (current.player.health <= 0) {
|
|
const recovery = await recoverPlayerThroughDefeatUi(page, result, collect, label);
|
|
record.playerRecoveryActions ??= [];
|
|
if (recovery.transition) record.playerRecoveryActions.push(recovery.transition);
|
|
current = recovery.snapshot;
|
|
if (recovery.recovered) {
|
|
record.outcome = 'player-recovered-route-replay-required';
|
|
break;
|
|
}
|
|
record.outcome = 'player-dead';
|
|
record.playerDeathRecoveryBlocked = recovery.reason;
|
|
break;
|
|
}
|
|
const alivePack = current.mobs.filter((mob) => !mob.dead && !mob.despawned && mob.engaged
|
|
&& hasFinitePosition(mob.position) && distance3(current.player.position, mob.position) <= 58);
|
|
const woundedMembers = 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);
|
|
const woundedMember = woundedMembers[0] ?? null;
|
|
const selfNeedsHealing = current.player.health < current.player.maxHealth * 0.68;
|
|
const groupHealMembers = woundedMembers.filter((member) => distance3(current.player.position, member.position) <= 120);
|
|
const groupHealCooldownEndsAt = Number(current.player.cooldowns?.[plan.groupHealing?.id] ?? 0);
|
|
const useGroupHeal = Boolean(plan.groupHealing && groupHealMembers.length >= 2 && !selfNeedsHealing
|
|
&& groupHealCooldownEndsAt <= current.now);
|
|
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 && current.now - lastResurrectionAt >= 2_000) {
|
|
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' });
|
|
lastResurrectionAt = Date.now();
|
|
await page.waitForTimeout(500);
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if ((woundedMember || selfNeedsHealing || useGroupHeal) && (plan.healing || useGroupHeal)) {
|
|
const isSelf = !useGroupHeal && selfNeedsHealing && (!woundedMember || current.player.health / current.player.maxHealth
|
|
< woundedMember.health / woundedMember.maxHealth);
|
|
const target = (isSelf || useGroupHeal) ? null : woundedMember;
|
|
const healingAction = useGroupHeal ? plan.groupHealing : plan.healing;
|
|
const targetPosition = (isSelf || useGroupHeal) ? current.player.position : target.position;
|
|
const maxRange = useGroupHeal ? 120 : Number(healingAction.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 || useGroupHeal) 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, healingAction, 'healing', (isSelf || useGroupHeal) ? '__player__' : target.id,
|
|
useGroupHeal ? `campaign-route group triage: ${groupHealMembers.length} nearby injured party members; Group Heal is learned and UI-bound`
|
|
: isSelf ? 'campaign-route survival: player below 68%' : `campaign-route triage: ${target.name} below 88%`);
|
|
recordHealOutcome(record, heal.cast, (isSelf || useGroupHeal) ? '__player__' : target.id,
|
|
useGroupHeal ? `Group Heal (${groupHealMembers.length} nearby injured party members)`
|
|
: isSelf ? profile.name : target.name);
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
const failureFeedback = [heal.cast.feedbackImmediatelyAfterInput, heal.cast.feedbackAtEnd, heal.cast.feedback]
|
|
.filter(Boolean);
|
|
const observedLosRejection = failureFeedback.some((feedback) => /not in line of sight/i.test(feedback.message ?? ''));
|
|
const recoveryTarget = (isSelf || useGroupHeal) ? null : current.party.find((member) => member.id === target.id);
|
|
if (observedLosRejection && recoveryTarget?.health > 0 && lastLosRecallAt === 0
|
|
&& current.game.overlay !== 'dungeon-results') {
|
|
const partyBeforeRecall = current.party.map((member) => ({ name: member.name, id: member.id,
|
|
distance: distance3(current.player.position, member.position) }));
|
|
await pressRouteKey(page, 'F4');
|
|
lastLosRecallAt = Date.now();
|
|
await page.waitForTimeout(180);
|
|
const recalled = await readLiveState(page);
|
|
collect(recalled);
|
|
const recallRecord = { at: lastLosRecallAt, trigger: 'explicit party-heal line-of-sight rejection',
|
|
control: 'F4 Recall party (normal UI)', targetId: target.id, targetName: target.name,
|
|
partyDistancesBefore: partyBeforeRecall,
|
|
partyDistancesAfter: recalled.party.map((member) => ({ name: member.name, id: member.id,
|
|
distance: distance3(recalled.player.position, member.position) })),
|
|
playerPosition: recalled.player.position, overlay: recalled.game.overlay };
|
|
(record.losRecoveryActions ??= []).push(recallRecord);
|
|
if (recalled.game.overlay !== 'dungeon-results') {
|
|
const retryTarget = recalled.party.find((member) => member.id === target.id && member.health > 0);
|
|
const retryDistance = retryTarget?.position ? distance3(recalled.player.position, retryTarget.position) : Infinity;
|
|
if (retryTarget && retryDistance <= Number(plan.healing.range?.max ?? 25) - 1.5) {
|
|
await selectPartyMember(page, retryTarget);
|
|
const retry = await performCast(page, result, plan.healing, 'healing', retryTarget.id,
|
|
`campaign-route LOS recovery retry after normal F4 party recall: ${retryTarget.name}`);
|
|
recordHealOutcome(record, retry.cast, retryTarget.id, retryTarget.name);
|
|
recallRecord.retry = {
|
|
targetId: retryTarget.id, targetHealthBefore: retry.cast.targetHealthBefore ?? null,
|
|
targetHealthAfter: retry.cast.targetHealthAfter ?? null,
|
|
allyDistanceAtInput: retry.cast.allyDistanceAtInput ?? retryDistance,
|
|
actionAccepted: retry.cast.actionAccepted ?? false,
|
|
actionResolved: retry.cast.actionResolved,
|
|
feedbackImmediatelyAfterInput: retry.cast.feedbackImmediatelyAfterInput ?? null,
|
|
feedbackAtEnd: retry.cast.feedbackAtEnd ?? retry.cast.feedback ?? null,
|
|
readiness: retry.cast.readiness ?? null,
|
|
};
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
record.outcome = 'dungeon-results-overlay-open-after-los-recovery-retry';
|
|
record.dungeonResultOverlay = current.game.overlay;
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
recallRecord.retry = { skipped: true, reason: retryTarget
|
|
? `recall left intended target outside ordinary heal range (${retryDistance.toFixed(2)}m)`
|
|
: 'intended target no longer alive after recall' };
|
|
}
|
|
}
|
|
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) {
|
|
if (current.game.overlay === 'dungeon-results') break;
|
|
await pressRouteKey(page, '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 (current.game.overlay === 'dungeon-results') {
|
|
record.outcome = 'dungeon-results-overlay-open-during-pack-targeting';
|
|
record.dungeonResultOverlay = current.game.overlay;
|
|
break;
|
|
}
|
|
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 pressRouteKey(page, 'F1');
|
|
record.attackCommands ??= [];
|
|
record.attackCommands.push({ at: Date.now(), key: 'F1', targetId: target.id, targetName: target.name });
|
|
await page.waitForTimeout(250);
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
record.outcome = 'dungeon-results-overlay-open-after-attack-command';
|
|
record.dungeonResultOverlay = current.game.overlay;
|
|
break;
|
|
}
|
|
await pressRouteKey(page, 'F2');
|
|
record.attackCommands.push({ at: Date.now(), key: 'F2', targetId: target.id, command: 'Defend at healer' });
|
|
current = await readLiveState(page);
|
|
collect(current);
|
|
if (current.game.overlay === 'dungeon-results') {
|
|
record.outcome = 'dungeon-results-overlay-open-after-defend-command';
|
|
record.dungeonResultOverlay = current.game.overlay;
|
|
break;
|
|
}
|
|
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.healAttempts = record.heals.length;
|
|
record.effectiveHealAttempts = record.heals.filter((heal) => heal.actionResolved).length;
|
|
record.failedHealAttemptCount = record.failedHealAttempts.length;
|
|
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();
|
|
if (route.id === DEFAULT_TEST_DUNGEON_ID) {
|
|
await selectDefaultTestDungeon(page);
|
|
} else {
|
|
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`) });
|
|
if (route.id !== DEFAULT_TEST_DUNGEON_ID) await row.click();
|
|
await openSelectedDungeonRoles(page);
|
|
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`) });
|
|
await page.screenshot({ path: path.join(outputDirectory, `${route.id}-player-equipped-silhouette-native-party.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 pressRouteKey(page, 'F1');
|
|
result.partyCommands = [{ at: Date.now(), key: 'F1', command: 'Attack', targetId: snapshot.player.selectedTargetId }];
|
|
await page.waitForTimeout(900);
|
|
await pressRouteKey(page, '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 eligibleRecipientIds = new Set(action.eligibleRecipientActorIds ?? [action.intendedTargetId]);
|
|
const actionEvents = playerEvents.filter((event) => event.abilityId === action.abilityId
|
|
&& eligibleRecipientIds.has(event.targetActorId)
|
|
&& 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,
|
|
targetActorName: event.targetActorId === '__player__' ? profile.name
|
|
: entryState.party.find((member) => member.id === event.targetActorId)?.name ?? null,
|
|
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', 'F2', 'F3', 'F4', 'Digit1', 'Digit2', 'Digit3', 'Digit4',
|
|
'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Escape'].includes(event.code)) {
|
|
window.nativeRouteKeyLog.push({ at: Date.now(), code: event.code, repeat: event.repeat,
|
|
shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey });
|
|
}
|
|
}, true);
|
|
window.nativeRouteStores = async () => {
|
|
const modules = await Promise.all(urls.map((url) => import(url)));
|
|
const namedExports = modules.flatMap((module, moduleIndex) => Object.entries(module)
|
|
.map(([name, value]) => [name, value, urls[moduleIndex]]));
|
|
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;
|
|
const abilityDamageSchoolDiscovery = window.nativeRouteSchoolHelperDiscovery
|
|
?? (() => {
|
|
const requiredLiterals = ['Explosive Trap', 'Envenom', 'Instant Poison', 'Deadly Poison', 'runewaker'];
|
|
const schoolRuleShape = (source) => source.includes('.school')
|
|
&& source.includes('fire|flame') && source.includes('frost|ice')
|
|
&& source.includes('nature|lightning') && source.includes('shadow|curse');
|
|
const candidatesByFunction = new Map();
|
|
for (const [name, value, moduleUrl] of namedExports) {
|
|
if (typeof value !== 'function') continue;
|
|
const source = Function.prototype.toString.call(value);
|
|
if (!requiredLiterals.every((literal) => source.includes(literal)) || !schoolRuleShape(source)) continue;
|
|
const candidate = candidatesByFunction.get(value) ?? { value, exports: [] };
|
|
candidate.exports.push({ name, moduleUrl });
|
|
candidatesByFunction.set(value, candidate);
|
|
}
|
|
const candidates = [...candidatesByFunction.values()];
|
|
const selected = candidates.length === 1 ? candidates[0] : null;
|
|
window.nativeRouteSchoolHelperFunction = selected?.value ?? null;
|
|
return {
|
|
requiredLiterals,
|
|
schoolRuleShapeVerified: candidates.length > 0,
|
|
candidateCount: candidates.length,
|
|
candidateExports: candidates.map(({ exports: names }) => names),
|
|
selectedExports: selected?.exports ?? [],
|
|
sourceSignature: 'canonical abilityDamageSchool literals + runewaker source + fire/frost/nature/shadow school rules',
|
|
};
|
|
})();
|
|
window.nativeRouteSchoolHelperDiscovery = abilityDamageSchoolDiscovery;
|
|
return {
|
|
getMobPosition: readTransform ? (id) => readTransform(id)?.position ?? null : null,
|
|
mobPositionExports: transformExport ? [transformExport[0]] : [],
|
|
getPartyPosition: readPartyPosition,
|
|
partyPositionExports: partyPositionExport ? [partyPositionExport[0]] : [],
|
|
abilityDamageSchool: window.nativeRouteSchoolHelperFunction ?? null,
|
|
abilityDamageSchoolDiscovery,
|
|
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);
|
|
if (!entry.dungeonRunStoreFound) {
|
|
throw new Error(`Dungeon-run progress store was not discovered at entry; refusing an unverifiable campaign run. Imported assets: ${moduleFiles.join(', ')}`);
|
|
}
|
|
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)}`);
|
|
const verifiedCampaignFullClear = !baseline && !stockadeVowDiagnostic && campaignBossRoute && completedRoutes.length > 0
|
|
&& completedRoutes.every((route) => route.fullClearStatus
|
|
=== 'native-full-dungeon-clear-and-all-required-killed-boss-ids-observed');
|
|
const resultStatus = stockadeVowDiagnostic
|
|
? completedRoutes.some((route) => route.vowDiagnostic?.confirmedCancellation)
|
|
? 'targeted-vow-diagnostic-confirmed-cancellation'
|
|
: completedRoutes.length > 0 ? 'targeted-vow-diagnostic-complete-cancellation-unverified'
|
|
: 'targeted-vow-diagnostic-not-assessed'
|
|
: campaignBossRoute
|
|
? baseline ? 'exploration-diagnostic-complete-full-clear-unverified'
|
|
: verifiedCampaignFullClear ? 'verified-full-clear'
|
|
: completedRoutes.length > 0 ? 'diagnostic-complete-full-clear-unverified'
|
|
: 'campaign-route-not-assessed'
|
|
: baseline ? 'exploration-diagnostic-complete' : 'complete';
|
|
const routeSummaries = completedRoutes.map((route) => {
|
|
const observedEvents = route.observedCombatEvents ?? [];
|
|
const playerDamageEvents = observedEvents.filter((event) => event.kind === 'damage'
|
|
&& event.sourceActorId === '__player__' && Number(event.effectiveAmount) > 0);
|
|
const effectivePlayerHealingEvents = observedEvents.filter((event) => event.kind === 'healing'
|
|
&& event.sourceActorId === '__player__' && Number(event.effectiveAmount) > 0);
|
|
const sourceHealingSummary = route.playerHealingEventSummary ?? null;
|
|
const vowAttempts = [
|
|
...(route.vowDiagnostic?.attempts ?? []),
|
|
...(route.bossEncounters ?? []).flatMap((encounter) => encounter.vowAttempts ?? []),
|
|
...(route.packEncounters ?? []).flatMap((encounter) => encounter.vowAttempts ?? []),
|
|
...(route.bossFight?.vowAttempts ?? []),
|
|
];
|
|
const evidenceTimes = [
|
|
...observedEvents.map((event) => Number(event.occurredAt)),
|
|
...(route.actions ?? []).map((action) => Number(action.at)),
|
|
...(route.packEncounters ?? []).map((encounter) => Number(encounter.endedAt)),
|
|
].filter(Number.isFinite);
|
|
const evidenceEndAt = Number(route.segmentEndedAt ?? route.dungeonResult?.observedAt
|
|
?? Math.max(Number(route.segmentStartedAt ?? 0), ...evidenceTimes));
|
|
const evidenceStartAt = Number(route.segmentStartedAt);
|
|
const observedEvidenceSpanMs = Number.isFinite(evidenceStartAt) && Number.isFinite(evidenceEndAt)
|
|
? Math.max(0, evidenceEndAt - evidenceStartAt) : null;
|
|
const nativeTimeMatch = route.dungeonResult?.visibleText?.match(/Time to final boss\s*\n?\s*([^\n]+)/i);
|
|
const allPngFiles = fs.readdirSync(outputDirectory).filter((file) => /\.png$/i.test(file)).sort();
|
|
const campaignHealingEventCount = sourceHealingSummary?.effectiveEventCount
|
|
?? effectivePlayerHealingEvents.length;
|
|
const campaignHealingAmount = sourceHealingSummary?.effectiveAmount
|
|
?? effectivePlayerHealingEvents.reduce((sum, event) => sum + Number(event.effectiveAmount ?? 0), 0);
|
|
return {
|
|
dungeonId: route.dungeonId,
|
|
fullClearStatus: route.fullClearStatus ?? null,
|
|
nativeFullClearVisible: route.dungeonResult?.fullClearVisible ?? false,
|
|
requiredKilledBossIdsComplete: route.dungeonResult?.requiredKilledBossIdsComplete ?? false,
|
|
routeBlocked: route.navigationResult?.blocked ?? null,
|
|
bossProgress: route.bossProgress ?? null,
|
|
coverage: route.coverage,
|
|
coverageEndpoint: route.coverageEndpoint,
|
|
mappedHealAvailable: route.mappedHealAvailable,
|
|
effectiveNativeHeals: campaignBossRoute ? campaignHealingEventCount : route.effectiveNativeHeals ?? 0,
|
|
totalEffectiveNativeHealing: campaignBossRoute ? campaignHealingAmount : route.totalEffectiveNativeHealing ?? 0,
|
|
healingRecipientScope: campaignBossRoute
|
|
? 'all source-__player__ healing events, including self and any eligible summon recipients'
|
|
: 'entry-party recipients counted by the ordinary segment summary',
|
|
effectivePlayerDamageEvents: campaignBossRoute
|
|
? playerDamageEvents.length : route.effectivePlayerDamageEvents ?? 0,
|
|
totalEffectivePlayerDamage: campaignBossRoute
|
|
? playerDamageEvents.reduce((sum, event) => sum + Number(event.effectiveAmount ?? 0), 0)
|
|
: route.totalEffectivePlayerDamage ?? 0,
|
|
playerHealingEventSummary: sourceHealingSummary,
|
|
healingInputWindowSummary: route.healingInputWindowSummary ?? null,
|
|
playerDeathCount: route.playerDeathTransitions?.length ?? 0,
|
|
partyCasualties: (route.partyCasualties ?? []).map((casualty) => ({
|
|
name: casualty.name, observedAt: casualty.observedAt, recoveredAt: casualty.recoveredAt,
|
|
healthAtRecovery: casualty.healthAtRecovery,
|
|
})),
|
|
partyRecoveryActions: route.partyRecoveryUiActions ?? [],
|
|
finalPartySurvivors: [
|
|
...(route.playerAtEndpoint?.health > 0 ? [{ name: route.profile?.name ?? 'player',
|
|
health: route.playerAtEndpoint.health, maxHealth: route.playerAtEndpoint.maxHealth }] : []),
|
|
...(route.finalPartySurvivors ?? []).map((member) => ({
|
|
name: member.name, health: member.health, maxHealth: member.maxHealth,
|
|
})),
|
|
],
|
|
finalPartyDeaths: route.finalPartyDeaths ?? [],
|
|
vowDiagnostic: {
|
|
status: route.vowDiagnostic?.status ?? null,
|
|
targetBossId: route.vowDiagnostic?.targetBossId ?? null,
|
|
targetBossName: route.vowDiagnostic?.targetBossName ?? null,
|
|
learned: route.vowSetup?.learned ?? false,
|
|
menuAssignmentObserved: route.vowSetup?.menuAssignmentObserved ?? false,
|
|
input: route.vowDiagnostic?.input ?? route.vowSetup?.input ?? null,
|
|
observedCastWindowCount: route.vowDiagnostic?.observedWindows?.length ?? 0,
|
|
attemptCount: vowAttempts.length,
|
|
confirmedCancellationCount: route.vowDiagnostic?.confirmedCancellation ? 1
|
|
: vowAttempts.filter((attempt) => attempt.confirmedCancellation || attempt.cancelledBeforeScheduledRelease).length,
|
|
confirmedCancellation: route.vowDiagnostic?.confirmedCancellation ?? null,
|
|
outcomeAttempts: route.vowDiagnostic?.attempts ?? [],
|
|
stopReason: route.vowDiagnostic?.stopReason ?? null,
|
|
},
|
|
cameraInputAtEntrance: route.cameraInputAtEntrance ?? null,
|
|
screenshotFilesPresent: allPngFiles,
|
|
campaignTiming: campaignBossRoute ? {
|
|
requestedDeadlineMs: maxCampaignDurationMs,
|
|
observedEvidenceSpanMs,
|
|
evidenceSpanBasis: route.segmentEndedAt || route.dungeonResult?.observedAt
|
|
? 'campaign segment start to final native-state read'
|
|
: 'campaign segment start to last captured event or pack state',
|
|
nativeTimeToFinalBoss: nativeTimeMatch?.[1]?.trim() ?? null,
|
|
} : null,
|
|
};
|
|
});
|
|
fs.writeFileSync(path.join(outputDirectory, 'results.json'), `${JSON.stringify({
|
|
status: resultStatus,
|
|
executionStatus: 'complete',
|
|
campaignFullClearVerified: stockadeVowDiagnostic ? false
|
|
: campaignBossRoute && completedRoutes.length > 0 ? verifiedCampaignFullClear : null,
|
|
dungeonRunStoreOwnerAsset: dungeonRunStoreAssetFile,
|
|
snapshotKind: baseline ? 'exploration-current-dist; final-build rerun required' : 'final-build route QA',
|
|
buildAssets: moduleFiles,
|
|
routeDurationMs: campaignBossRoute ? null : routeDurationMs,
|
|
profile,
|
|
rosterUi,
|
|
configuredSegmentDurationMs: campaignBossRoute ? null : routeDurationMs,
|
|
maxCampaignDurationMs: campaignBossRoute ? maxCampaignDurationMs : null,
|
|
targetedVowDiagnostic: stockadeVowDiagnostic,
|
|
routes: routeSummaries,
|
|
pageErrors,
|
|
consoleErrors,
|
|
}, null, 2)}\n`);
|
|
const statusLabel = resultStatus === 'verified-full-clear' ? 'verified full clear'
|
|
: resultStatus === 'targeted-vow-diagnostic-confirmed-cancellation'
|
|
? 'targeted Vow cancellation confirmed; full clear not attempted'
|
|
: resultStatus === 'targeted-vow-diagnostic-complete-cancellation-unverified'
|
|
? 'targeted Vow diagnostic complete; cancellation unverified'
|
|
: resultStatus === 'diagnostic-complete-full-clear-unverified'
|
|
|| resultStatus === 'exploration-diagnostic-complete-full-clear-unverified'
|
|
? 'diagnostic complete; full clear unverified'
|
|
: resultStatus === 'campaign-route-not-assessed' ? 'campaign route not assessed'
|
|
: baseline ? 'exploration baseline complete' : 'complete';
|
|
console.log(`Native campaign route ${statusLabel}: ${outputDirectory}`);
|
|
} catch (error) {
|
|
await page.screenshot({ path: path.join(outputDirectory, 'failure.png') }).catch(() => {});
|
|
const failed = { status: 'failed', dungeonRunStoreOwnerAsset: dungeonRunStoreAssetFile,
|
|
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; });
|