const { DEFAULT_TEST_DUNGEON_ID, DEFAULT_TEST_DUNGEON_TITLE, openSelectedDungeonRoles, selectDefaultTestDungeon, } = require('./playtest-defaults.cjs'); const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright'); const assert = require('node:assert/strict'); const crypto = require('node:crypto'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); // Focused Stockade evidence for current RoM Group Heal, exact native party // complements, recurring Auto Shot and the owned Blood Arrow HP cost. Profiles // are created through the UI; only their isolated level/secondary class is // prepared before dungeon entry. All entry, ability binding, movement, // targeting, facing and casting use ordinary UI input. Store reads are telemetry. const runId = new Date().toISOString().replace(/[-:.]/g, '').replace('T', '-').replace('Z', ''); const outputDirectory = path.resolve(`playtest-artifacts/rom-healing-ranged-stockade/${runId}`); const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rom-healing-ranged-')); const distDirectory = path.resolve('dist'); const assetsDirectory = path.join(distDirectory, 'assets'); const PLAYER_ID = '__player__'; const GROUP_HEAL_SOURCE_ID = 490281; const AUTO_SHOT_SOURCE_ID = 492589; const BLOOD_ARROW_SOURCE_ID = 490434; const GROUP_HEAL_RADIUS_METERS = 12; const NATIVE_FIVE = [ { classId: 'rom-knight', secondaryClassId: 'rom-scout', role: 'tank' }, { classId: 'rom-priest', secondaryClassId: 'rom-knight', role: 'healer' }, { classId: 'rom-rogue', secondaryClassId: 'rom-warrior', role: 'damage' }, { classId: 'rom-scout', secondaryClassId: 'rom-rogue', role: 'damage' }, { classId: 'rom-mage', secondaryClassId: 'rom-warlock', role: 'damage' }, ]; const FIXTURES = [ // Character Forge canonicalizes user-entered names to an initial capital // followed by lowercase letters, so use that rendered identity throughout. { key: 'priest-knight', name: 'Romhealpk', className: 'Priest', classId: 'rom-priest', secondaryClassId: 'rom-knight', role: 'healer' }, { key: 'scout-rogue', name: 'Romrangesr', className: 'Scout', classId: 'rom-scout', secondaryClassId: 'rom-rogue', role: 'damage' }, ]; const MODULE_BUNDLE_PATTERN = /^(?:index|GameRuntime|combatStore|partyStore|PartyBridge|PartyFrames|romAbilityCatalog|partyRuntimeRegistry)-.*\.js$/; fs.mkdirSync(outputDirectory, { recursive: true }); function sha256(bytes) { return crypto.createHash('sha256').update(bytes).digest('hex'); } function listJavaScriptAssets(directory) { if (!fs.existsSync(directory)) return []; const files = []; for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) files.push(...listJavaScriptAssets(fullPath)); else if (entry.isFile() && entry.name.endsWith('.js')) files.push(fullPath); } return files.sort(); } function collectBuildManifest() { const assetFiles = listJavaScriptAssets(assetsDirectory); const assets = assetFiles.map((fullPath) => ({ path: path.relative(distDirectory, fullPath).replaceAll(path.sep, '/'), bytes: fs.statSync(fullPath).size, sha256: sha256(fs.readFileSync(fullPath)), })); const htmlPath = path.join(distDirectory, 'index.html'); const bundleFiles = assetFiles.filter((fullPath) => MODULE_BUNDLE_PATTERN.test(path.basename(fullPath))); const distIndex = fs.existsSync(htmlPath) ? { bytes: fs.statSync(htmlPath).size, sha256: sha256(fs.readFileSync(htmlPath)), } : null; const fingerprint = { distIndexSha256: distIndex?.sha256 ?? null, assets: assets.map(({ path: assetPath, bytes, sha256: assetSha256 }) => ({ path: assetPath, bytes, sha256: assetSha256 })) }; return { distIndex, assetManifestSha256: sha256(Buffer.from(JSON.stringify(fingerprint))), javascriptAssetCount: assets.length, assets, storeModuleFiles: bundleFiles.map((fullPath) => path.relative(distDirectory, fullPath).replaceAll(path.sep, '/')), storeModuleUrls: bundleFiles.map((fullPath) => `/${path.relative(distDirectory, fullPath).replaceAll(path.sep, '/')}`), }; } const buildManifest = collectBuildManifest(); const moduleUrls = buildManifest.storeModuleUrls; const expectedFrozenManifest = process.argv.find((argument) => argument.startsWith('--frozen-manifest-sha256=')) ?.slice('--frozen-manifest-sha256='.length) ?? null; function stableIdentity(member) { return `${member.classId}:${member.secondaryClassId ?? 'none'}:${member.role}`; } function partyIdentitiesFor(player) { return NATIVE_FIVE.filter((identity) => identity.classId !== player.classId || identity.secondaryClassId !== player.secondaryClassId) .map(stableIdentity).sort(); } function sourceSpellId(ability) { return ability?.dbcSpellId ?? ability?.sourceSkillId ?? null; } function actionBindingAudit(snapshot) { const bindings = snapshot.player.actionBindings ?? {}; const abilities = new Map(snapshot.player.abilities.map((ability) => [ability.id, ability])); const rows = Object.entries(bindings).map(([binding, abilityId]) => ({ binding, layer: binding.split(':')[0] ?? null, control: binding.split(':')[1] ?? null, abilityId, ability: abilityId ? abilities.get(abilityId) ?? null : null, resolvesToCurrentRuntimeAbility: !abilityId || abilities.has(abilityId), })); return { layers: Object.fromEntries(['primary', 'secondary', 'tertiary'].map((layer) => [layer, Object.fromEntries(rows.filter((row) => row.layer === layer) .map(({ control, abilityId }) => [control, abilityId]))])), rows, unresolvedAbilityIds: rows.filter((row) => row.abilityId && !row.resolvesToCurrentRuntimeAbility) .map((row) => ({ binding: row.binding, abilityId: row.abilityId })), }; } function distance3(left, right) { if (!left || !right) return null; return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]); } function angleDelta(from, to) { return Math.atan2(Math.sin(to - from), Math.cos(to - from)); } 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 capture(page, name) { const file = path.join(outputDirectory, `${name}.png`); await page.screenshot({ path: file, fullPage: false }); return path.basename(file); } async function prepareStores(page) { await page.addInitScript(({ urls }) => { window.romHealingRangedStores = async () => { const modules = await Promise.all(urls.map((url) => import(url))); const values = modules.flatMap((module) => Object.values(module)); const namedExports = modules.flatMap((module) => Object.entries(module)); const stores = values.filter((value) => value && typeof value.getState === 'function'); const stateOf = (value) => { try { return value?.getState?.(); } catch { return undefined; } }; 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; const getPartyRuntimePosition = namedExports.find(([name, value]) => name === 'getPartyRuntimePosition' && typeof value === 'function')?.[1] ?? null; return { getMobPosition: readTransform ? (id) => readTransform(id)?.position ?? null : null, getPartyRuntimePosition: getPartyRuntimePosition ?? (() => null), game: stores.find((store) => typeof stateOf(store)?.activeDungeonId === 'string' || 'activeDungeonId' in (stateOf(store) ?? {})), shell: stores.find((store) => typeof stateOf(store)?.saveActiveProgression === 'function'), combat: stores.find((store) => typeof stateOf(store)?.castAbility === 'function' && Array.isArray(stateOf(store)?.auras)), party: stores.find((store) => Array.isArray(stateOf(store)?.members) && typeof stateOf(store)?.setCommand === 'function'), dungeonRun: stores.find((store) => stateOf(store)?.progress && typeof stateOf(store)?.reset === 'function') ?? null, }; }; }, { urls: moduleUrls }); } async function liveSnapshot(page) { return page.evaluate(async (playerId) => { const { game, shell, combat, party, dungeonRun, getMobPosition, getPartyRuntimePosition } = await window.romHealingRangedStores(); const gameState = game.getState(); const shellState = shell.getState(); const state = combat.getState(); const selectedCharacter = shellState.characters?.find((entry) => entry.id === shellState.selectedCharacterId) ?? null; const memberRows = party.getState().members.map((member) => ({ id: member.id, name: member.name, classId: member.classId, secondaryClassId: member.secondaryClassId ?? null, role: member.role, statRuleset: member.statRuleset ?? null, level: member.level ?? null, health: member.health, maxHealth: member.maxHealth, resource: member.resource, maxResource: member.maxResource, resourceType: member.resourceType, resourceName: member.resourceName, position: getPartyRuntimePosition(member.id), cooldowns: member.cooldowns ?? null, actionBindings: member.actionBindings ?? null, })); const relevantRuntimeState = Object.fromEntries(Object.entries(state) .filter(([key, value]) => /auto.?shot|ranged|blood.?arrow|last.?offensive|attack.?speed/i.test(key) && typeof value !== 'function')); return { observedAt: Date.now(), game: { activeDungeonId: gameState.activeDungeonId, gameMode: gameState.gameMode, gmTestMode: gameState.gmTestMode, bossTest: gameState.bossTest, assetStatus: gameState.assetStatus, overlay: gameState.overlay, dungeonIntroActive: gameState.dungeonIntroActive, playerPosition: gameState.playerPosition, cameraYaw: gameState.cameraYaw, areaName: gameState.areaName, }, profile: selectedCharacter ? { id: selectedCharacter.id, name: selectedCharacter.name, classId: selectedCharacter.classId, secondaryClassId: selectedCharacter.secondaryClassId ?? null, level: selectedCharacter.level, statRuleset: selectedCharacter.statRuleset ?? null, categoryId: selectedCharacter.categoryId, equipment: selectedCharacter.equipment ?? null, inventory: selectedCharacter.inventory ?? null, gearStats: selectedCharacter.gearStats ?? null, actionBindings: selectedCharacter.actionBindings ?? null, } : null, player: { classId: state.classId, secondaryClassId: state.secondaryClassId ?? null, level: state.level, statRuleset: state.statRuleset, role: party.getState().playerRole ?? null, health: state.health, maxHealth: state.maxHealth, resource: state.resource, maxResource: state.maxResource, resourceType: state.resourceType, resourceName: state.resourceName, resourcePools: state.resourcePools ?? null, maxResourcePools: state.maxResourcePools ?? null, selectedTargetId: state.selectedTargetId, activeCast: state.activeCast?.abilityId ?? null, cooldowns: state.cooldowns, feedback: state.feedback ?? null, actionBindings: state.actionBindings ?? null, autoShot: { targetId: state.autoShotTargetId ?? null, nextShotAt: state.nextAutoShotAt ?? 0, }, abilities: (state.abilities ?? []).map((ability) => ({ id: ability.id, name: ability.name, classId: ability.classId, secondaryClassId: ability.secondaryClassId ?? null, romSkillGroup: ability.romSkillGroup ?? null, unlockLevel: ability.unlockLevel, passive: ability.passive, source: ability.source, dbcSpellId: ability.dbcSpellId ?? null, sourceSkillId: ability.sourceSkillId ?? null, sourceAttackKind: ability.sourceAttackKind ?? null, description: ability.description ?? '', target: ability.target, range: ability.range, radius: ability.radius ?? null, castTimeMs: ability.castTimeMs ?? null, cooldownMs: ability.cooldownMs ?? null, cost: ability.cost, effects: ability.effects ?? [], })), auras: (state.auras ?? []).map((aura) => ({ instanceId: aura.instanceId, name: aura.definition?.name, targetId: aura.targetId, sourceId: aura.sourceId, expiresAt: aura.expiresAt, utilityTags: aura.definition?.utilityTags ?? [], id: aura.definition?.id, })), effects: (state.effects ?? []).filter((effect) => effect.sourceActorId === playerId || effect.targetId === playerId).map((effect) => ({ id: effect.id, sourceAbilityId: effect.sourceAbilityId, sourceActorId: effect.sourceActorId, targetId: effect.targetId, kind: effect.kind, expiresAt: effect.expiresAt, nextTickAt: effect.nextTickAt, amount: effect.amount, })), combatEvents: (state.combatEvents ?? []).slice(-1_000), mobs: Object.values(state.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, engaged: mob.engaged, despawned: mob.despawned, position: getMobPosition?.(mob.id) ?? mob.position ?? mob.homePosition, sourceNpcId: mob.runewakerNpcId ?? null, })), equipment: state.equipment ?? null, inventory: state.inventory ?? null, gearStats: state.gearStats ?? null, relevantRuntimeState, }, party: memberRows, partyCommand: party.getState().command ?? null, dungeonRun: dungeonRun?.getState?.().progress ?? null, }; }, PLAYER_ID); } async function createProfile(page, fixture) { 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: new RegExp(`^${fixture.className}`) }).first().click(); await page.getByRole('button', { name: /Customize & name/ }).click(); await page.getByLabel('Character name').fill(fixture.name); assert.equal(await page.getByLabel('Character name').inputValue(), fixture.name); await page.getByRole('button', { name: /Create character/ }).click(); const rosterRow = page.locator('.roster-character').filter({ hasText: fixture.name }); await rosterRow.waitFor({ state: 'visible', timeout: 30_000 }); await rosterRow.click(); const profile = await page.evaluate(async (requested) => { const { shell } = await window.romHealingRangedStores(); const state = shell.getState(); const character = state.characters.find((entry) => entry.id === state.selectedCharacterId); if (!character || character.name !== requested.name || character.classId !== requested.classId) { throw new Error(`UI-created profile ${requested.name} is missing or has the wrong primary class.`); } shell.setState({ activeCharacter: character }); shell.getState().saveActiveProgression(30, 0, character.talentRanks ?? {}, character.glyphIds ?? []); shell.setState({ activeCharacter: null }); if (!shell.getState().chooseRomSecondaryClass(requested.secondaryClassId)) { throw new Error(`${requested.name} did not accept secondary class ${requested.secondaryClassId}.`); } const updated = shell.getState().characters.find((entry) => entry.id === character.id); return { id: updated.id, name: updated.name, classId: updated.classId, secondaryClassId: updated.secondaryClassId ?? null, level: updated.level, statRuleset: updated.statRuleset ?? null, raceId: updated.raceId, categoryId: updated.categoryId, equipment: updated.equipment ?? null, inventory: updated.inventory ?? null, gearStats: updated.gearStats ?? null, actionBindings: updated.actionBindings ?? null, }; }, fixture); assert.equal(profile.classId, fixture.classId); assert.equal(profile.secondaryClassId, fixture.secondaryClassId); assert.equal(profile.level, 30); assert.equal(profile.statRuleset, 'rom'); await page.locator('.character-preview canvas').waitFor({ timeout: 180_000 }); await page.waitForTimeout(1_000); return { profile, rosterScreenshot: await capture(page, `${fixture.key}-roster`), preparation: 'Profile was created through the UI; level 30 and RoM secondary class were prepared in isolated pre-entry progression state.' }; } async function selectProfile(page, fixture) { const row = page.locator('.roster-character').filter({ hasText: fixture.name }); if (await row.count()) await row.click(); const continueButton = page.getByRole('button', { name: new RegExp(`Continue as ${fixture.name}`) }); if (await continueButton.count()) await continueButton.click({ position: { x: 24, y: 30 } }); else await page.locator('.roster-enter').click(); await page.locator('.main-menu-screen').waitFor(); } async function enterStockade(page, fixture) { await page.getByRole('button', { name: /^Dungeons/ }).click(); await page.locator('.dungeon-select-screen').waitFor(); await selectDefaultTestDungeon(page); await openSelectedDungeonRoles(page); const role = page.locator(`.dungeon-role-option--${fixture.role}`); await role.waitFor({ state: 'visible', timeout: 180_000 }); await role.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((expectedId) => window.romHealingRangedStores().then(({ game }) => { const state = game.getState(); return state.activeDungeonId === expectedId && state.assetStatus === 'ready'; }), DEFAULT_TEST_DUNGEON_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 entry = await liveSnapshot(page); assert.equal(entry.game.activeDungeonId, DEFAULT_TEST_DUNGEON_ID); assert.equal(entry.game.gameMode, 'dungeon'); assert.equal(entry.game.gmTestMode, false); assert.equal(entry.game.bossTest, null); assert.equal(entry.player.classId, fixture.classId); assert.equal(entry.player.secondaryClassId, fixture.secondaryClassId); assert.equal(entry.player.level, 30); assert.equal(entry.player.statRuleset, 'rom'); assert.equal(entry.player.role, fixture.role); assert.equal(entry.party.length, 4); assert(entry.party.every((member) => member.statRuleset === 'rom'), 'All four generated companions use native RoM stats.'); assert(entry.party.every((member) => Number.isFinite(member.maxHealth) && member.maxHealth > 0), 'All four native companions have initialized health stats.'); const actualParty = entry.party.map(stableIdentity).sort(); const expectedParty = partyIdentitiesFor(fixture); assert.deepEqual(actualParty, expectedParty, `Ordinary entry generated the exact native-five complement for ${fixture.classId}/${fixture.secondaryClassId}.`); const expectedRoleByIdentity = new Map(NATIVE_FIVE.map((identity) => [stableIdentity(identity), identity.role])); assert(entry.party.every((member) => expectedRoleByIdentity.get(stableIdentity(member)) === member.role), 'All companions have their native five role assignments.'); const evidence = { dungeonPicker: await capture(page, `${fixture.key}-stockade-picker`), dungeonEntrance: await capture(page, `${fixture.key}-stockade-entrance-party`), }; return { entry, partyComposition: { exactComplement: true, expected: expectedParty, actual: entry.party.map((member) => ({ identity: stableIdentity(member), statRuleset: member.statRuleset, role: member.role, level: member.level, health: member.health, maxHealth: member.maxHealth, resourceType: member.resourceType, maxResource: member.maxResource })), allActionBindingLayersAtEntry: actionBindingAudit(entry), }, evidence, }; } async function bindAbilityWithUi(page, ability, control) { const assignmentButton = page.getByRole('button', { name: /Button assignments/ }); if (await assignmentButton.isVisible().catch(() => false)) { await assignmentButton.click(); } else { const paused = page.getByRole('dialog', { name: 'Paused' }); if (!(await paused.isVisible().catch(() => false))) await page.keyboard.press('Escape'); await paused.waitFor({ state: 'visible', timeout: 10_000 }); await page.getByRole('button', { name: 'Ability controls', exact: true }).click(); } await page.getByRole('dialog', { name: 'Ability controls' }).waitFor({ state: 'visible' }); const controllerLayout = page.getByRole('button', { name: 'Controller bars', exact: true }); if (await controllerLayout.getAttribute('aria-pressed') !== 'true') await controllerLayout.click(); await page.locator(`.ability-binding-slot--${control}`).click(); const choice = page.locator('.ability-bindings__choices button').filter({ has: page.getByText(ability.name, { exact: true }), }).first(); await choice.waitFor({ state: 'visible' }); await choice.click(); await page.getByText(/Saved\./).last().waitFor({ state: 'visible' }); await page.getByRole('button', { name: 'Close button assignments' }).click(); await page.getByRole('dialog', { name: 'Ability controls' }).waitFor({ state: 'detached' }); } async function ensurePrimaryBinding(page, ability, control) { const before = await liveSnapshot(page); const existing = Object.entries(before.player.actionBindings ?? {}) .filter(([, abilityId]) => abilityId === ability.id).map(([binding]) => binding); const key = `primary:${control}`; if (before.player.actionBindings?.[key] !== ability.id) await bindAbilityWithUi(page, ability, control); const after = await liveSnapshot(page); assert.equal(after.player.actionBindings?.[key], ability.id, `${ability.name} was assigned through the rendered ability-controls UI to ${key}.`); if (after.game.overlay === 'pause') await page.getByRole('button', { name: 'Resume game', exact: true }).click(); return { priorBindingsForAbility: existing, binding: key, allLayersAfter: actionBindingAudit(after) }; } async function activateActionSlot(page, control) { const slot = page.locator(`.action-slot--${control}`); await slot.waitFor({ state: 'visible', timeout: 15_000 }); await page.waitForFunction((selector) => { const element = document.querySelector(selector); return Boolean(element && element.getAttribute('aria-disabled') !== 'true'); }, `.action-slot--${control}`, { timeout: 30_000 }); assert.notEqual(await slot.getAttribute('aria-disabled'), 'true'); const bar = page.locator('.action-bar').first(); const barState = { className: await bar.getAttribute('class'), ariaLabel: await bar.getAttribute('aria-label') }; await slot.click(); return { input: 'ordinary rendered action-bar pointer click', barState }; } async function approachRangedTarget(page, targetId, maximumRange) { const steps = []; for (let index = 0; index < 18; index += 1) { const before = await liveSnapshot(page); const target = before.player.mobs.find((mob) => mob.id === targetId); if (!target || target.dead || target.despawned || !target.position) break; const distanceBefore = distance3(before.game.playerPosition, target.position); if (distanceBefore <= Math.max(0, maximumRange - 0.75)) break; const keys = movementKeysTo(before.game.playerPosition, target.position, before.game.cameraYaw ?? 0); if (!keys.length) break; for (const key of keys) await page.keyboard.down(key); try { await page.waitForTimeout(350); } finally { for (const key of keys) await page.keyboard.up(key); } const after = await liveSnapshot(page); const nextTarget = after.player.mobs.find((mob) => mob.id === targetId); steps.push({ keys: keys.map((key) => key.toUpperCase()), distanceBefore, distanceAfter: nextTarget?.position ? distance3(after.game.playerPosition, nextTarget.position) : null, positionBefore: before.game.playerPosition, positionAfter: after.game.playerPosition }); if (!nextTarget || nextTarget.dead || nextTarget.despawned) break; } const final = await liveSnapshot(page); const target = final.player.mobs.find((mob) => mob.id === targetId); const distance = target?.position ? distance3(final.game.playerPosition, target.position) : null; return { steps, distance, withinRange: distance !== null && distance <= maximumRange }; } async function faceTargetWithOrdinaryRightDrag(page, targetId) { const before = await liveSnapshot(page); const target = before.player.mobs.find((mob) => mob.id === targetId); if (!target?.position || !before.game.playerPosition || !Number.isFinite(before.game.cameraYaw)) { return { status: 'not_observable', reason: 'Target/player position or camera yaw is unavailable.' }; } const deltaX = target.position[0] - before.game.playerPosition[0]; const deltaZ = target.position[2] - before.game.playerPosition[2]; const desiredYaw = Math.atan2(deltaX, deltaZ); const headingErrorBefore = angleDelta(before.game.cameraYaw, desiredYaw); const facingArcLimit = Math.PI / 2 - 0.08; const evidence = { input: 'ordinary held right-mouse drag over the game canvas', yawRadiansPerPixel: 0.0021, playerFacingSource: 'game.cameraYaw; world +Z is forward', targetId, playerPosition: before.game.playerPosition, targetPosition: target.position, cameraYawBefore: before.game.cameraYaw, desiredYaw, headingErrorBefore, facingArcLimitRadians: facingArcLimit }; if (Math.abs(headingErrorBefore) <= facingArcLimit) { return { status: 'already_inside_combat_facing_arc', evidence, pointerLockElement: await page.evaluate(() => document.pointerLockElement?.tagName ?? null) }; } const plannedResidualYaw = 0.55; const requestedYawDelta = headingErrorBefore - Math.sign(headingErrorBefore) * plannedResidualYaw; let requestedMouseDx = -requestedYawDelta / evidence.yawRadiansPerPixel; 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) }; }); evidence.canvas = canvasInfo; if (!canvasInfo.canvasFound || canvasInfo.maxHorizontalDrag <= 0) { return { status: 'not_applied_canvas_unavailable', evidence }; } if (Math.abs(requestedMouseDx) > canvasInfo.maxHorizontalDrag) { requestedMouseDx = Math.sign(requestedMouseDx) * canvasInfo.maxHorizontalDrag; evidence.boundedPartialTurn = true; } const path = await page.evaluate(({ dx, margin }) => { const canvas = document.querySelector('canvas'); if (!canvas) return { rows: [], selectedRow: null }; const rect = canvas.getBoundingClientRect(); const startX = dx >= 0 ? rect.left + margin : rect.right - margin; const endX = startX + dx; const sampleCount = Math.max(1, Math.ceil(Math.abs(dx) / 40)); const rows = [0.35, 0.5, 0.6, 0.7].map((fraction) => { const y = Math.max(rect.top + margin, Math.min(rect.bottom - margin, rect.top + rect.height * fraction)); const points = Array.from({ length: sampleCount + 1 }, (_, index) => startX + dx * index / sampleCount); const allHitCanvas = points.every((x) => { const hit = document.elementFromPoint(x, y); return Boolean(hit && (hit === canvas || canvas.contains(hit))); }); return { fraction, y, startX, endX, allHitCanvas }; }); return { rows, selectedRow: rows.find((row) => row.allHitCanvas) ?? null, sampleSpacingPx: Math.abs(dx) / sampleCount }; }, { dx: requestedMouseDx, margin: canvasInfo.margin }); evidence.hitTest = path; if (!path.selectedRow) return { status: 'not_applied_no_clear_canvas_path', evidence }; const steps = Math.max(1, Math.ceil(Math.abs(requestedMouseDx) / 100)); if (Math.abs(requestedMouseDx) / steps > 120) return { status: 'not_applied_delta_exceeds_safe_frame_bound', evidence }; const { startX, endX, y } = path.selectedRow; await page.mouse.move(startX, y); await page.mouse.down({ button: 'right' }); try { await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve))); for (let index = 1; index <= steps; index += 1) { await page.mouse.move(startX + (endX - startX) * index / steps, y); await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve))); } } finally { await page.mouse.up({ button: 'right' }); } await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve))); const after = await liveSnapshot(page); const measuredYawDelta = angleDelta(before.game.cameraYaw, after.game.cameraYaw); const headingErrorAfter = angleDelta(after.game.cameraYaw, desiredYaw); const pointerLockElement = await page.evaluate(() => document.pointerLockElement?.tagName ?? null); evidence.cameraYawAfter = after.game.cameraYaw; evidence.measuredYawDelta = measuredYawDelta; evidence.requestedYawDelta = -requestedMouseDx * evidence.yawRadiansPerPixel; evidence.requestedYawDeltaMatchedWithinTolerance = Math.abs(angleDelta(evidence.requestedYawDelta, measuredYawDelta)) <= 0.15; evidence.headingErrorAfter = headingErrorAfter; evidence.cameraLookReleased = true; evidence.pointerLockElement = pointerLockElement; return { status: Math.abs(headingErrorAfter) <= facingArcLimit && evidence.requestedYawDeltaMatchedWithinTolerance && pointerLockElement === null ? 'ordinary_right_drag_faced_target_inside_arc' : 'ordinary_right_drag_did_not_confirm_facing', evidence }; } async function selectedTarget(page) { await page.keyboard.press('Tab'); await page.waitForFunction(() => window.romHealingRangedStores().then(({ combat }) => { const state = combat.getState(); const target = state.mobs?.[state.selectedTargetId]; return Boolean(target && !target.dead && !target.despawned); }), null, { timeout: 8_000 }).catch(() => {}); const snapshot = await liveSnapshot(page); return { snapshot, target: snapshot.player.mobs.find((mob) => mob.id === snapshot.player.selectedTargetId) ?? null }; } function eventWatermark(snapshot) { return Math.max(0, ...(snapshot.player.combatEvents ?? []).map((event) => Number(event.id) || 0)); } function eventsAfter(snapshot, watermark) { return (snapshot.player.combatEvents ?? []).filter((event) => (Number(event.id) || 0) > watermark); } function matchingEvents(after, before, predicate) { return eventsAfter(after, eventWatermark(before)).filter(predicate); } function sourceAbility(abilities, sourceId, identity) { return abilities.find((ability) => ability.source === 'runewaker' && sourceSpellId(ability) === sourceId && ability.classId === identity.classId && (ability.secondaryClassId ?? null) === identity.secondaryClassId && !ability.passive) ?? null; } async function exerciseGroupHeal(page, fixture, entryResult) { const beforeDiscover = await liveSnapshot(page); const groupHeal = sourceAbility(beforeDiscover.player.abilities, GROUP_HEAL_SOURCE_ID, { classId: 'rom-priest', secondaryClassId: null }); const discover = { learnedAbilities: beforeDiscover.player.abilities.filter((ability) => ability.unlockLevel <= beforeDiscover.player.level) .map((ability) => ({ id: ability.id, name: ability.name, classId: ability.classId, secondaryClassId: ability.secondaryClassId, sourceSpellId: sourceSpellId(ability), unlockLevel: ability.unlockLevel, romSkillGroup: ability.romSkillGroup, passive: ability.passive })), groupHeal: groupHeal ? { id: groupHeal.id, name: groupHeal.name, sourceSpellId: sourceSpellId(groupHeal), unlockLevel: groupHeal.unlockLevel, radius: groupHeal.radius, castTimeMs: groupHeal.castTimeMs, target: groupHeal.target, effects: groupHeal.effects } : null, allActionBindingLayersBefore: actionBindingAudit(beforeDiscover), }; assert(groupHeal, `Level-${beforeDiscover.player.level} Priest/Knight runtime learned source Group Heal ${GROUP_HEAL_SOURCE_ID}.`); const binding = await ensurePrimaryBinding(page, groupHeal, 'face-bottom'); const screenshot = await capture(page, `${fixture.key}-group-heal-ready`); const attackOrder = page.getByRole('button', { name: /^Attack party order\./ }); await attackOrder.waitFor({ state: 'visible', timeout: 10_000 }); await attackOrder.click(); assert.equal(await attackOrder.getAttribute('aria-pressed'), 'true', 'The ordinary Attack party order is active to produce a bounded natural encounter.'); const attackOrderEvidence = { input: 'ordinary rendered Attack party order click', label: await attackOrder.getAttribute('aria-label'), ariaPressed: await attackOrder.getAttribute('aria-pressed'), snapshotAfterOrder: await liveSnapshot(page) }; const engagement = await selectedTarget(page); const injurySamples = []; const castAttempts = []; const injuryDeadline = Date.now() + 100_000; const hasNaturalInjury = (snapshot) => [ { id: PLAYER_ID, health: snapshot.player.health, maxHealth: snapshot.player.maxHealth }, ...snapshot.party, ].some((member) => Number(member.maxHealth) > 0 && member.health / member.maxHealth < 0.995); let currentTargetId = engagement.target?.id ?? null; let latestSnapshot = await liveSnapshot(page); while (Date.now() < injuryDeadline && castAttempts.length < 4) { const liveTarget = latestSnapshot.player.mobs.find((mob) => mob.id === currentTargetId); if (!liveTarget || liveTarget.dead || liveTarget.despawned) { const nextTarget = await selectedTarget(page); currentTargetId = nextTarget.target?.id ?? null; latestSnapshot = nextTarget.snapshot; const target = nextTarget.target; if (target?.position && currentTargetId) { const approach = await approachRangedTarget(page, currentTargetId, Math.max(5, Number(groupHeal.range?.max ?? 5))); injurySamples.push({ kind: 'ordinary_retarget_approach', targetId: currentTargetId, approach }); } } let preCast = latestSnapshot; const attemptDeadline = Math.min(injuryDeadline, Date.now() + 45_000); while (!hasNaturalInjury(preCast) && Date.now() < attemptDeadline) { await page.waitForTimeout(2_000); preCast = await liveSnapshot(page); injurySamples.push({ observedAt: preCast.observedAt, player: { health: preCast.player.health, maxHealth: preCast.player.maxHealth }, party: preCast.party.map((member) => ({ id: member.id, health: member.health, maxHealth: member.maxHealth, position: member.position })), selectedTargetId: preCast.player.selectedTargetId, activeMobs: preCast.player.mobs.filter((mob) => mob.engaged && !mob.dead).map((mob) => ({ id: mob.id, name: mob.name, health: mob.health, maxHealth: mob.maxHealth, position: mob.position, })) }); const selected = preCast.player.mobs.find((mob) => mob.id === preCast.player.selectedTargetId); if (!selected || selected.dead || selected.despawned) { const nextTarget = await selectedTarget(page); if (nextTarget.target?.id && nextTarget.target.id !== currentTargetId) { currentTargetId = nextTarget.target.id; preCast = nextTarget.snapshot; if (nextTarget.target.position) { const approach = await approachRangedTarget(page, currentTargetId, Math.max(5, Number(groupHeal.range?.max ?? 5))); injurySamples.push({ kind: 'ordinary_retarget_approach', targetId: currentTargetId, approach }); } } } } latestSnapshot = preCast; if (!hasNaturalInjury(preCast)) break; const beforeGroupHealEvent = eventWatermark(preCast); const input = await activateActionSlot(page, 'face-bottom'); await page.waitForTimeout(Math.min(Number(groupHeal.castTimeMs ?? 0), 8_000) + 1_250); const after = await liveSnapshot(page); const newGroupHealEvents = eventsAfter(after, beforeGroupHealEvent).filter((event) => event.abilityId === groupHeal.id && [PLAYER_ID, 'player', fixture.name].includes(event.sourceActorId)); const healingEvents = newGroupHealEvents.filter((event) => event.kind === 'healing'); const castEvents = newGroupHealEvents.filter((event) => event.kind !== 'healing'); const playerPosition = preCast.game.playerPosition; const recipients = [...new Set(healingEvents.map((event) => event.targetActorId))]; const positions = new Map([ [PLAYER_ID, playerPosition], ['player', playerPosition], ...preCast.party.map((member) => [member.id, member.position]), ]); const recipientDistances = recipients.map((id) => ({ targetActorId: id, distanceFromObservedPlayerOriginMeters: distance3(playerPosition, positions.get(id) ?? null), appearsInsideSourced12mRadius: distance3(playerPosition, positions.get(id) ?? null) === null ? null : distance3(playerPosition, positions.get(id)) <= GROUP_HEAL_RADIUS_METERS })); const attempt = { attempt: castAttempts.length + 1, selectedTargetId: preCast.player.selectedTargetId, before: preCast, after, matchingRuntimeEvents: newGroupHealEvents, castEvents, healingEvents, observedRecipients: recipients, recipientDistances, ordinaryActionBarInput: input, screenshot: await capture(page, `${fixture.key}-group-heal-attempt-${castAttempts.length + 1}`) }; castAttempts.push(attempt); latestSnapshot = after; if (healingEvents.some((event) => Number(event.effectiveAmount ?? event.amount ?? 0) > 0)) break; const readyAt = Number(after.player.cooldowns?.[groupHeal.id] ?? 0); if (readyAt > Date.now() && readyAt < injuryDeadline) await page.waitForTimeout(readyAt - Date.now() + 100); } const successfulAttempt = castAttempts.find((attempt) => attempt.healingEvents.some((event) => Number(event.effectiveAmount ?? event.amount ?? 0) > 0)) ?? null; const lastAttempt = castAttempts.at(-1) ?? null; const recipientDistances = successfulAttempt?.recipientDistances ?? lastAttempt?.recipientDistances ?? []; const recipients = successfulAttempt?.observedRecipients ?? lastAttempt?.observedRecipients ?? []; const healingEvents = castAttempts.flatMap((attempt) => attempt.healingEvents); const castEvents = castAttempts.flatMap((attempt) => attempt.castEvents); const newGroupHealEvents = castAttempts.flatMap((attempt) => attempt.matchingRuntimeEvents); const preCast = successfulAttempt?.before ?? lastAttempt?.before ?? latestSnapshot; const after = successfulAttempt?.after ?? lastAttempt?.after ?? latestSnapshot; const stopOrder = page.getByRole('button', { name: /^Stop party order\./ }); let boundedStop = null; if (await stopOrder.isVisible().catch(() => false)) { await stopOrder.click(); boundedStop = { input: 'ordinary rendered Stop party order click', ariaPressed: await stopOrder.getAttribute('aria-pressed'), snapshotAfterStop: await liveSnapshot(page), screenshot: await capture(page, `${fixture.key}-group-heal-encounter-stopped`) }; } const result = { status: successfulAttempt ? 'effective_player_group_heal_observed' : newGroupHealEvents.length ? 'matching_group_heal_runtime_event_without_effective_heal' : 'no_effective_player_group_heal_observed', ability: discover.groupHeal, binding, targetAndApproach: { initiallySelectedTargetId: engagement.target?.id ?? null, finalSelectedTargetId: latestSnapshot.player.selectedTargetId }, observedSourcedRadiusMeters: GROUP_HEAL_RADIUS_METERS, naturalInjuryObservedBeforeCast: hasNaturalInjury(preCast), boundedEncounter: { attackOrderEvidence, stopOrder: boundedStop }, injurySamples, attempts: castAttempts, before: preCast, after, matchingRuntimeEvents: newGroupHealEvents, castEvents, healingEvents, observedRecipients: recipients, recipientDistances, inputScreenshot: screenshot, afterScreenshot: await capture(page, `${fixture.key}-group-heal-after-observation`), observationDurationMs: latestSnapshot.observedAt - entryResult.entry.observedAt, limitations: [ 'Party positions and event-recipient IDs are read-only runtime telemetry. Distances are descriptive; the harness does not alter health, stage a boundary actor, inject LOS, or promise a casualty.', 'Natural incoming damage, NPC healing and regeneration can overlap the cast. Runtime healing events are the primary evidence; aggregate health deltas are not isolated cost or recipient proof.', 'The 12 m exact boundary, living filter and LOS behavior are covered by the focused source integration tests, not synthesized in this live run.', ], }; return { discover, binding, result, entry: entryResult }; } async function exerciseAutoShotAndBloodArrow(page, fixture, entryResult) { const discovered = await liveSnapshot(page); const autoShot = sourceAbility(discovered.player.abilities, AUTO_SHOT_SOURCE_ID, { classId: 'rom-scout', secondaryClassId: null }); const bloodArrow = sourceAbility(discovered.player.abilities, BLOOD_ARROW_SOURCE_ID, { classId: 'rom-scout', secondaryClassId: null }); const discovery = { learnedAbilities: discovered.player.abilities.filter((ability) => ability.unlockLevel <= discovered.player.level) .map((ability) => ({ id: ability.id, name: ability.name, classId: ability.classId, secondaryClassId: ability.secondaryClassId, sourceSpellId: sourceSpellId(ability), unlockLevel: ability.unlockLevel, romSkillGroup: ability.romSkillGroup, passive: ability.passive })), autoShot: autoShot ? { id: autoShot.id, name: autoShot.name, sourceSpellId: sourceSpellId(autoShot), unlockLevel: autoShot.unlockLevel, range: autoShot.range, castTimeMs: autoShot.castTimeMs, cooldownMs: autoShot.cooldownMs, effects: autoShot.effects } : null, bloodArrow: bloodArrow ? { id: bloodArrow.id, name: bloodArrow.name, sourceSpellId: sourceSpellId(bloodArrow), unlockLevel: bloodArrow.unlockLevel, cooldownMs: bloodArrow.cooldownMs, cost: bloodArrow.cost, effects: bloodArrow.effects } : null, allActionBindingLayersBefore: actionBindingAudit(discovered), equipmentAtEntry: discovered.player.equipment, gearStatsAtEntry: discovered.player.gearStats, }; assert(autoShot, `Level-${discovered.player.level} Scout/Rogue runtime learned source Auto Shot ${AUTO_SHOT_SOURCE_ID}.`); assert(bloodArrow, `Level-${discovered.player.level} Scout/Rogue runtime learned source Blood Arrow ${BLOOD_ARROW_SOURCE_ID}.`); assert.equal(autoShot.id, 'rom-scout-primary-57', 'The current source Auto Shot logical ability ID is loaded.'); const autoBinding = await ensurePrimaryBinding(page, autoShot, 'face-bottom'); const arrowBinding = await ensurePrimaryBinding(page, bloodArrow, 'face-left'); const beforeToggle = await liveSnapshot(page); const existingOwnedArrow = beforeToggle.player.auras.filter((aura) => aura.sourceId === PLAYER_ID && aura.targetId === PLAYER_ID && (aura.utilityTags.includes('rom-self-health-drain') || aura.name === 'Blood Arrow')); let bloodArrowActivation; if (existingOwnedArrow.length) { bloodArrowActivation = { status: 'already_active_before_direct_input', auras: existingOwnedArrow, note: 'No action-bar input was sent because the same toggle is also the ordinary cancel input.' }; } else { const activationInput = await activateActionSlot(page, 'face-left'); await page.waitForTimeout(700); const afterArrow = await liveSnapshot(page); const active = afterArrow.player.auras.filter((aura) => aura.sourceId === PLAYER_ID && aura.targetId === PLAYER_ID && (aura.utilityTags.includes('rom-self-health-drain') || aura.name === 'Blood Arrow')); bloodArrowActivation = { status: active.length ? 'activated_by_rendered_action_bar' : 'attempted_without_owned_aura', activationInput, before: beforeToggle, after: afterArrow, auras: active }; } const bloodArrowScreenshot = await capture(page, `${fixture.key}-blood-arrow-state`); const maximumRange = Number(autoShot.range?.max ?? autoShot.range ?? 30); const targetProbes = []; const uniqueEnemyHits = new Map(); let cancellationAndRestart = { status: 'opportunistic_not_confirmed' }; const initialPartyCommand = discovered.partyCommand; let partyStopFallback = null; let priorTargetId = null; for (let attempt = 0; attempt < 5 && uniqueEnemyHits.size < 2; attempt += 1) { const targeting = await selectedTarget(page); const targetId = targeting.target?.id ?? null; if (!targetId || !targeting.target?.position) { targetProbes.push({ attempt: attempt + 1, status: 'no_live_target', targeting }); break; } if (targetId === priorTargetId) { targetProbes.push({ attempt: attempt + 1, status: 'tab_did_not_select_a_different_target', targetId }); break; } priorTargetId = targetId; const approach = await approachRangedTarget(page, targetId, maximumRange); const afterApproach = await liveSnapshot(page); const targetAfterApproach = afterApproach.player.mobs.find((mob) => mob.id === targetId); if (!targetAfterApproach || targetAfterApproach.dead || targetAfterApproach.despawned) { targetProbes.push({ attempt: attempt + 1, status: 'target_died_during_ordinary_approach', targetId, approach, afterApproach }); continue; } const facing = await faceTargetWithOrdinaryRightDrag(page, targetId); const beforeStart = await liveSnapshot(page); const startWatermark = eventWatermark(beforeStart); const startInput = await activateActionSlot(page, 'face-bottom'); await page.waitForFunction((expectedTargetId) => window.romHealingRangedStores().then(({ combat }) => combat.getState().autoShotTargetId === expectedTargetId), targetId, { timeout: 5_000 }).catch(() => {}); const afterStart = await liveSnapshot(page); const startStateConfirmed = afterStart.player.autoShot?.targetId === targetId; const mobIds = new Set([...beforeStart.player.mobs, ...afterStart.player.mobs].map((mob) => mob.id)); const hitEvents = []; const windowEvents = []; const playerSamples = []; let watermark = startWatermark; let last = afterStart; const observationDeadline = Date.now() + 24_000; while (Date.now() < observationDeadline && hitEvents.length < 2) { await page.waitForTimeout(1_250); last = await liveSnapshot(page); const candidateEvents = eventsAfter(last, watermark); windowEvents.push(...candidateEvents); for (const event of candidateEvents) { if (startStateConfirmed && event.kind === 'damage' && event.abilityId === autoShot.id && (event.sourceActorId === PLAYER_ID || event.sourceActorId === 'player') && mobIds.has(event.targetActorId) && event.school === 'physical' && Number(event.effectiveAmount) > 0) { hitEvents.push(event); uniqueEnemyHits.set(event.id, event); } } watermark = Math.max(watermark, eventWatermark(last)); playerSamples.push({ observedAt: last.observedAt, health: last.player.health, maxHealth: last.player.maxHealth, resource: last.player.resource, maxResource: last.player.maxResource, autoShot: last.player.autoShot, bloodArrowOwnedAuras: last.player.auras.filter((aura) => aura.sourceId === PLAYER_ID && aura.targetId === PLAYER_ID && (aura.utilityTags.includes('rom-self-health-drain') || aura.name === 'Blood Arrow')) }); const mob = last.player.mobs.find((candidate) => candidate.id === targetId); if (!mob || mob.dead || mob.despawned || last.player.autoShot?.targetId !== targetId) break; } const afterWindow = await liveSnapshot(page); windowEvents.push(...eventsAfter(afterWindow, watermark)); const rawEventWindow = [...new Map(windowEvents.map((event) => [event.id, event])).values()]; const playerIncomingDamage = rawEventWindow.filter((event) => event.kind === 'damage' && (event.targetActorId === PLAYER_ID || event.targetActorId === 'player')); const playerHealing = rawEventWindow.filter((event) => event.kind === 'healing' && (event.targetActorId === PLAYER_ID || event.targetActorId === 'player')); const probe = { attempt: attempt + 1, targetId, targetAtStart: beforeStart.player.mobs.find((mob) => mob.id === targetId), approach, facing, ordinaryActionBarStartInput: startInput, startStateConfirmed, beforeStart, afterStart, afterWindow, observationDurationMs: afterWindow.observedAt - afterStart.observedAt, playerHealthSamples: playerSamples, rawEventWindow, enemyHitEvents: hitEvents, playerIncomingDamageEvents: playerIncomingDamage, playerHealingEvents: playerHealing, screenshot: await capture(page, `${fixture.key}-auto-shot-target-${attempt + 1}`) }; targetProbes.push(probe); const endedTarget = afterWindow.player.mobs.find((mob) => mob.id === targetId); if (startStateConfirmed && hitEvents.length < 2 && (!endedTarget || endedTarget.dead || endedTarget.despawned) && !partyStopFallback) { const stopOrder = page.getByRole('button', { name: /^Stop party order\./ }); if (await stopOrder.isVisible().catch(() => false)) { await stopOrder.click(); partyStopFallback = { condition: 'A normal pull target ended before two Auto Shot hits were recorded.', input: 'ordinary rendered Stop party order click after the failed target probe', ariaPressed: await stopOrder.getAttribute('aria-pressed'), snapshotAfterStop: await liveSnapshot(page), screenshot: await capture(page, `${fixture.key}-auto-shot-party-stop-fallback`) }; } } if (uniqueEnemyHits.size >= 2 && afterWindow.player.autoShot?.targetId === targetId) { let partyOrderCycle = { originalCommand: initialPartyCommand, temporaryStop: null, restoredOriginalOrder: null }; const liveBeforeCancel = await liveSnapshot(page); const targetBeforeCancel = liveBeforeCancel.player.mobs.find((mob) => mob.id === targetId); if (targetBeforeCancel && !targetBeforeCancel.dead && !targetBeforeCancel.despawned && ['attack', 'defend'].includes(initialPartyCommand)) { const stopOrder = page.getByRole('button', { name: /^Stop party order\./ }); if (await stopOrder.isVisible().catch(() => false)) { await stopOrder.click(); partyOrderCycle.temporaryStop = { input: 'ordinary rendered Stop party order click to protect the live retest target', ariaPressed: await stopOrder.getAttribute('aria-pressed'), snapshotAfterStop: await liveSnapshot(page), screenshot: await capture(page, `${fixture.key}-auto-shot-cancel-party-stopped`) }; } } const beforeCancel = await liveSnapshot(page); const cancelInput = await activateActionSlot(page, 'face-bottom'); await page.waitForTimeout(100); const afterCancel = await liveSnapshot(page); const cancellationConfirmed = afterCancel.player.autoShot?.targetId == null; const stoppedEvents = []; let stopWatermark = eventWatermark(afterCancel); if (cancellationConfirmed) { const stopUntil = Date.now() + 4_000; while (Date.now() < stopUntil) { await page.waitForTimeout(1_000); const sample = await liveSnapshot(page); const events = eventsAfter(sample, stopWatermark); stoppedEvents.push(...events); stopWatermark = Math.max(stopWatermark, eventWatermark(sample)); if (sample.player.autoShot?.targetId) break; } } const stillLive = (await liveSnapshot(page)).player.mobs.find((mob) => mob.id === targetId); let restart = { status: 'not_attempted_target_no_longer_live' }; if (cancellationConfirmed && stillLive && !stillLive.dead && !stillLive.despawned) { const beforeRestart = await liveSnapshot(page); const restartWatermark = eventWatermark(beforeRestart); const restartInput = await activateActionSlot(page, 'face-bottom'); await page.waitForFunction((expectedTargetId) => window.romHealingRangedStores().then(({ combat }) => combat.getState().autoShotTargetId === expectedTargetId), targetId, { timeout: 3_000 }).catch(() => {}); const afterRestart = await liveSnapshot(page); const restartConfirmed = afterRestart.player.autoShot?.targetId === targetId; restart = { status: restartConfirmed ? 'restarted_on_same_target' : 'restart_not_confirmed', beforeRestart, ordinaryActionBarInput: restartInput, afterRestart, enemyHitEvents: eventsAfter(afterRestart, restartWatermark).filter((event) => event.kind === 'damage' && event.abilityId === autoShot.id && (event.sourceActorId === PLAYER_ID || event.sourceActorId === 'player') && event.targetActorId === targetId && event.school === 'physical' && Number(event.effectiveAmount) > 0) }; if (restartConfirmed) { restart.screenshot = await capture(page, `${fixture.key}-auto-shot-restarted`); const stopRestartInput = await activateActionSlot(page, 'face-bottom'); await page.waitForTimeout(100); const afterRestartStop = await liveSnapshot(page); restart.ordinaryActionBarStopAfterRestart = stopRestartInput; restart.afterRestartStop = afterRestartStop; restart.stopAfterRestartConfirmed = afterRestartStop.player.autoShot?.targetId == null; } } if (partyOrderCycle.temporaryStop && ['attack', 'defend'].includes(initialPartyCommand)) { const restoreName = initialPartyCommand === 'attack' ? /^Attack party order\./ : /^Defend party order\./; const restoreOrder = page.getByRole('button', { name: restoreName }); if (await restoreOrder.isVisible().catch(() => false)) { await restoreOrder.click(); partyOrderCycle.restoredOriginalOrder = { command: initialPartyCommand, ariaPressed: await restoreOrder.getAttribute('aria-pressed'), snapshotAfterRestore: await liveSnapshot(page) }; } } cancellationAndRestart = { status: cancellationConfirmed ? (restart.status === 'restarted_on_same_target' ? 'ordinary_toggle_cancel_then_restart_observed' : 'ordinary_toggle_cancel_observed_restart_inconclusive') : 'ordinary_toggle_cancel_not_confirmed', beforeCancel, ordinaryActionBarCancelInput: cancelInput, afterCancel, stateTargetCleared: cancellationConfirmed, stoppedEvents, restart, partyOrderCycle, screenshot: await capture(page, `${fixture.key}-auto-shot-cancel-restart`), }; } } if (partyStopFallback && ['attack', 'defend'].includes(initialPartyCommand)) { const restoreName = initialPartyCommand === 'attack' ? /^Attack party order\./ : /^Defend party order\./; const restoreOrder = page.getByRole('button', { name: restoreName }); if (await restoreOrder.isVisible().catch(() => false)) { await restoreOrder.click(); partyStopFallback.restoredOriginalOrder = { command: initialPartyCommand, ariaPressed: await restoreOrder.getAttribute('aria-pressed'), snapshotAfterRestore: await liveSnapshot(page), screenshot: await capture(page, `${fixture.key}-auto-shot-party-order-restored`) }; } } const autoShotEvidence = { status: uniqueEnemyHits.size >= 2 ? 'recurring_auto_shot_enemy_hits_observed' : uniqueEnemyHits.size === 1 ? 'single_auto_shot_enemy_hit_observed' : 'no_auto_shot_enemy_hit_observed', sourceContract: { autoShotAbilityId: autoShot.id, sourceSpellId: AUTO_SHOT_SOURCE_ID, owner: 'Scout/Rogue', schedulerState: { targetId: 'autoShotTargetId', nextShotAt: 'nextAutoShotAt' }, enemyHitEvent: 'positive physical damage event from the player to a real Stockade mob, tagged with Auto Shot ability ID' }, targetProbes, enemyHitEvents: [...uniqueEnemyHits.values()], cancellationAndRestart, partyStopFallback: partyStopFallback ?? { status: 'not_needed' }, bloodArrowCostObservation: { ownedAuraObservedAtStart: bloodArrowActivation.status === 'already_active_before_direct_input' || bloodArrowActivation.status === 'activated_by_rendered_action_bar', activation: bloodArrowActivation, playerHealthSamples: targetProbes.flatMap((probe) => probe.playerHealthSamples ?? []), playerIncomingDamageEvents: targetProbes.flatMap((probe) => probe.playerIncomingDamageEvents ?? []), playerHealingEvents: targetProbes.flatMap((probe) => probe.playerHealingEvents ?? []), separateSelfCostEventExpected: false, accountingLimit: 'The current source contract debits 2% max HP at a scheduled Auto Shot commit without emitting a separate self-damage event. Raw HP/max HP, resource, incoming damage, healing and shot windows are preserved; overlapping natural combat prevents a live numeric cost claim. Focused unit/integration coverage owns exact 2% cost evidence.', }, limitations: [ 'At least two positive physical player-to-enemy combat events tagged with the source Auto Shot ability ID are required before recurring ranged behavior is treated as observed.', 'A real target may die before a second scheduled shot. The runner preserves the probe and retries up to five real targets through Tab, paced movement, ordinary facing drag and normal action-bar input; it does not stage enemy HP.', 'The source intentionally emits no separate Blood Arrow self-damage event, so HP deltas are descriptive only and are not exact cost proof.', ], }; return { discovery, bindings: { autoShot: autoBinding, bloodArrow: arrowBinding }, bloodArrowActivation, bloodArrowScreenshot, autoShot: autoShotEvidence, entry: entryResult, }; } async function backToCharacterSelect(page) { await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Main menu', exact: true }).waitFor({ state: 'visible', timeout: 15_000 }); await page.getByRole('button', { name: 'Main menu', exact: true }).click(); await page.locator('.main-menu-screen').waitFor(); await page.getByRole('button', { name: 'Change character', exact: true }).click(); await page.locator('.character-select-screen').waitFor(); } function writeJson(filename, value) { fs.writeFileSync(path.join(outputDirectory, filename), `${JSON.stringify(value, null, 2)}\n`); } (async () => { assert(fs.existsSync(path.join(distDirectory, 'index.html')), 'A completed dist/index.html is present; the harness does not build it.'); assert(buildManifest.javascriptAssetCount > 0, 'Dist contains JavaScript assets to fingerprint.'); assert(moduleUrls.length >= 3, `Current source-store module bundles were discovered by role names: ${moduleUrls.join(', ')}`); if (expectedFrozenManifest) { assert.equal(buildManifest.assetManifestSha256, expectedFrozenManifest, 'Current dist asset manifest matches the coordinated frozen-build fingerprint.'); } const { createGameServer } = await import('../server/server.mjs'); const runtime = createGameServer({ dataDir: dataDirectory, staticDir: distDirectory, enableShelvedModes: false }); await new Promise((resolve) => runtime.server.listen(0, '127.0.0.1', resolve)); const origin = `http://127.0.0.1:${runtime.server.address().port}`; let browser; let page; const pageErrors = []; const consoleErrors = []; const httpErrors = []; const servedBuildAssets = []; const run = { status: 'running', runId, startedAt: new Date().toISOString(), dungeon: { id: DEFAULT_TEST_DUNGEON_ID, title: DEFAULT_TEST_DUNGEON_TITLE, variant: 'bite-sized' }, scope: 'Two bounded level-30 RoM fixtures through ordinary Cellblock Rush entry; the seeded boss is recorded from the live run and no full-source-dungeon clear is claimed.', build: buildManifest, fixtures: [], pageErrors, consoleErrors, httpErrors, servedBuildAssets, }; try { browser = await chromium.launch({ channel: 'msedge', headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' }); page = await context.newPage(); page.setDefaultTimeout(90_000); page.on('pageerror', (error) => pageErrors.push(error.message)); page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() }); }); page.on('response', (response) => { const url = new URL(response.url()); if (url.pathname.endsWith('.js')) { const assetPath = url.pathname.replace(/^\//, ''); const asset = buildManifest.assets.find((candidate) => candidate.path === assetPath); servedBuildAssets.push({ status: response.status(), path: assetPath, sha256: asset?.sha256 ?? null, bytes: asset?.bytes ?? null }); } if (response.status() >= 400) httpErrors.push({ status: response.status(), url: response.url() }); }); page.on('requestfailed', (request) => httpErrors.push({ status: 'failed', url: request.url(), failure: request.failure()?.errorText })); await prepareStores(page); await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 120_000 }); await page.getByRole('button', { name: /Play offline/ }).click(); await page.locator('.character-select-screen').waitFor(); run.rosterProfiles = []; for (const fixture of FIXTURES) run.rosterProfiles.push(await createProfile(page, fixture)); const healer = FIXTURES[0]; await selectProfile(page, healer); const healerEntry = await enterStockade(page, healer); run.fixtures.push({ key: healer.key, profile: healer, entry: healerEntry, groupHeal: await exerciseGroupHeal(page, healer, healerEntry) }); await backToCharacterSelect(page); const ranged = FIXTURES[1]; await selectProfile(page, ranged); const rangedEntry = await enterStockade(page, ranged); run.fixtures.push({ key: ranged.key, profile: ranged, entry: rangedEntry, ranged: await exerciseAutoShotAndBloodArrow(page, ranged, rangedEntry) }); run.finishedAt = new Date().toISOString(); run.durationMs = Date.parse(run.finishedAt) - Date.parse(run.startedAt); const finalBuildManifest = collectBuildManifest(); run.buildFreezeCheck = { startManifestSha256: buildManifest.assetManifestSha256, finishManifestSha256: finalBuildManifest.assetManifestSha256, unchangedThroughoutRun: finalBuildManifest.assetManifestSha256 === buildManifest.assetManifestSha256 }; assert.equal(run.buildFreezeCheck.unchangedThroughoutRun, true, 'The recorded dist asset manifest stayed unchanged during the browser run.'); run.browserOutcome = pageErrors.length === 0 ? 'no-page-errors-observed' : 'page-errors-observed'; const groupHeal = run.fixtures[0].groupHeal.result; const effectiveGroupHealObserved = groupHeal.healingEvents.some((event) => event.kind === 'healing' && event.abilityId === groupHeal.ability.id && Number(event.effectiveAmount) > 0 && [PLAYER_ID, 'player', FIXTURES[0].name].includes(event.sourceActorId)); const rangedHits = run.fixtures[1].ranged.autoShot.enemyHitEvents; const recurringAutoShotHitsObserved = new Set(rangedHits.map((event) => event.id)).size >= 2; const exactComplementsObserved = run.fixtures.every((fixture) => fixture.entry.partyComposition.exactComplement); run.requiredEvidence = { effectivePlayerGroupHealEvent: effectiveGroupHealObserved, twoDistinctAutoShotEnemyHitsAfterRenderedUiStart: recurringAutoShotHitsObserved, exactNativeFiveComplementAtBothEntries: exactComplementsObserved, }; run.status = Object.values(run.requiredEvidence).every(Boolean) ? 'complete' : 'inconclusive'; writeJson('results.json', run); if (run.status === 'complete') { console.log(`RoM healing/ranged Stockade diagnostic completed: ${outputDirectory}`); } else { console.error(`RoM healing/ranged Stockade diagnostic is inconclusive; required evidence is missing: ${outputDirectory}`); process.exitCode = 2; } } catch (error) { run.status = 'failed'; run.finishedAt = new Date().toISOString(); run.error = { name: error.name, message: error.message, stack: error.stack }; run.bodyText = await page?.locator('body').innerText().catch(() => '') ?? ''; if (page) await capture(page, 'failure').catch(() => undefined); writeJson('failure.json', run); console.error(run.bodyText.slice(-3_000)); console.error('Page errors:', pageErrors); console.error('Console errors:', consoleErrors); console.error('HTTP errors:', httpErrors); 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-rom-healing-ranged-')) { throw new Error('Unsafe temporary playtest database cleanup path.'); } fs.rmSync(target, { recursive: true, force: true }); } })().catch((error) => { console.error(error); process.exitCode = 1; });