Files
healer-man/scripts/playtest-rom-healing-ranged.cjs
2026-10-06 22:25:34 -04:00

1224 lines
66 KiB
JavaScript

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; });