forked from phenom/healer-man
1224 lines
66 KiB
JavaScript
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; });
|