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_CATEGORY, FULL_STOCKADE_DUNGEON_ID, FULL_STOCKADE_DUNGEON_TITLE, FULL_STOCKADE_BOSS, selectDefaultTestDungeon, selectFullStockadeDungeon, 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: DEFAULT_TEST_DUNGEON_CATEGORY }, { id: FULL_STOCKADE_DUNGEON_ID, title: FULL_STOCKADE_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 (campaignBossRoute && !routeSelection) { throw new Error('--campaign-boss-route requires an explicit --route= selection.'); } if (stockadeVowDiagnostic && (!campaignBossRoute || routeSelection !== FULL_STOCKADE_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 === FULL_STOCKADE_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 === FULL_STOCKADE_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 === FULL_STOCKADE_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 === FULL_STOCKADE_BOSS && (boss.activeCast?.spellId === 421 || boss.activeCast?.attackId === 'spell-421' || /chain lightning/i.test(boss.activeCast?.name ?? '')) ? boss.activeCast : null; if (route.id === FULL_STOCKADE_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 === FULL_STOCKADE_DUNGEON_ID ? await assignVowThroughAbilityControls(page) : null; if (route.id === FULL_STOCKADE_DUNGEON_ID && vowSetup?.menuAssignmentObserved) entryState = await readLiveState(page, true); const plan = actionPlanFromSnapshot(entryState); const hamhockObjective = guidance.objectives.find((objective) => objective.name === FULL_STOCKADE_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 ?? FULL_STOCKADE_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 === FULL_STOCKADE_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 === FULL_STOCKADE_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 if (route.id === FULL_STOCKADE_DUNGEON_ID) { await selectFullStockadeDungeon(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 && route.id !== FULL_STOCKADE_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; });