Files
healer-man/scripts/playtest-stockade-blood-arrow.cjs
T
2026-10-06 22:25:34 -04:00

1118 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
// Targeted Stockade Blood Arrow proof. Profiles are created through the UI;
// only their isolated level and RoM secondary class are prepared before entry.
// Dungeon entry, bindings, movement, targets, casts and aura cancellation use
// the ordinary UI. Store reads below are telemetry only.
const runId = new Date().toISOString().replace(/[-:.]/g, '').replace('T', '-').replace('Z', '');
const outputDirectory = path.resolve(`playtest-artifacts/rom-blood-arrow-stockade/${runId}`);
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-stockade-blood-arrow-'));
const moduleFiles = fs.readdirSync('dist/assets')
.filter((file) => /^(?:index|GameRuntime|combatStore|partyStore|PartyFrames|romAbilityCatalog)-.*\.js$/.test(file));
const moduleUrls = moduleFiles.map((file) => `/assets/${file}`);
const PLAYER_ID = '__player__';
const BLOOD_ARROW_SOURCE_ID = 490434;
const AUTO_SHOT_SOURCE_ID = 492589;
const DEACTIVATION_COOLDOWN_MS = 10_000;
const EXPECTED_FINAL_BUILD_ASSETS = [
'index-CpWq1F33.js',
'GameRuntime-CSfKe7bD.js',
'combatStore-D4Bif_EK.js',
'PartyFrames-BDJ5ssyc.js',
'romAbilityCatalog-Dh-3_mot.js',
];
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 = [
{ key: 'knight-scout', name: 'Bloodarrowks', className: 'Knight', classId: 'rom-knight', secondaryClassId: 'rom-scout' },
{ key: 'scout-rogue', name: 'Bloodarrowsr', className: 'Scout', classId: 'rom-scout', secondaryClassId: 'rom-rogue' },
];
fs.mkdirSync(outputDirectory, { recursive: true });
function stableIdentity(member) {
return `${member.classId}:${member.secondaryClassId}:${member.role}`;
}
function partyIdentitiesFor(player) {
return NATIVE_FIVE.filter((identity) => identity.classId !== player.classId
|| identity.secondaryClassId !== player.secondaryClassId)
.map(stableIdentity).sort();
}
function bloodArrowAura(aura) {
return aura?.definition?.utilityTags?.includes('rom-self-health-drain') === true
|| aura?.definition?.name === 'Blood Arrow';
}
function isKSRangedSkill(ability) {
const effects = (ability.effects ?? []).flatMap((effect) => effect.kind === 'composite' ? effect.effects : [effect]);
const sourceId = ability.dbcSpellId ?? ability.sourceSkillId;
const supportedIdentity = (ability.classId === 'rom-scout'
&& ability.secondaryClassId == null
&& ability.romSkillGroup === 'general')
|| (ability.classId === 'rom-knight' && ability.secondaryClassId === 'rom-scout');
return ability.source === 'runewaker'
&& !ability.passive
&& sourceId !== BLOOD_ARROW_SOURCE_ID
&& sourceId !== AUTO_SHOT_SOURCE_ID
&& ability.sourceAttackKind !== 'melee'
&& effects.some((effect) => effect.kind === 'damage' || effect.kind === 'dot')
&& supportedIdentity
&& (/ranged weapon DPS/i.test(ability.description ?? '') || sourceId === 490420);
}
function isSRRangedSkill(ability) {
const effects = (ability.effects ?? []).flatMap((effect) => effect.kind === 'composite' ? effect.effects : [effect]);
const sourceId = ability.dbcSpellId ?? ability.sourceSkillId;
return ability.source === 'runewaker'
&& !ability.passive
&& sourceId !== BLOOD_ARROW_SOURCE_ID
&& sourceId !== AUTO_SHOT_SOURCE_ID
&& ability.sourceAttackKind !== 'melee'
&& effects.some((effect) => effect.kind === 'damage' || effect.kind === 'dot')
&& ability.classId === 'rom-scout'
&& ability.secondaryClassId == null
&& ['general', 'primary'].includes(ability.romSkillGroup)
&& (/ranged weapon DPS/i.test(ability.description ?? '') || sourceId === 490420);
}
async function pageStores(page) {
return page.evaluate(() => window.stockadeBloodArrowStores());
}
async function liveSnapshot(page) {
return page.evaluate(async (playerId) => {
const { game, shell, combat, party, dungeonRun, getMobPosition } = await window.stockadeBloodArrowStores();
const gameState = game.getState();
const shellState = shell.getState();
const state = combat.getState();
const active = (state.auras ?? []).filter((aura) => aura.targetId === playerId
&& aura.sourceId === playerId
&& (aura.expiresAt === null || aura.expiresAt > Date.now()));
const activeBloodArrow = active.filter((aura) => aura.definition?.utilityTags?.includes('rom-self-health-drain')
|| aura.definition?.name === 'Blood Arrow');
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,
health: member.health,
maxHealth: member.maxHealth,
resource: member.resource,
maxResource: member.maxResource,
resourceType: member.resourceType,
resourceName: member.resourceName,
cooldowns: member.cooldowns ?? null,
}));
const ownerIds = new Set(memberRows.map((member) => member.id));
const npcBloodArrowAuras = (state.auras ?? []).filter((aura) => ownerIds.has(aura.targetId)
&& aura.targetId === aura.sourceId
&& (aura.expiresAt === null || aura.expiresAt > Date.now())
&& (aura.definition?.utilityTags?.includes('rom-self-health-drain')
|| aura.definition?.name === 'Blood Arrow'))
.map((aura) => ({
instanceId: aura.instanceId,
ownerId: aura.targetId,
name: aura.definition?.name,
expiresAt: aura.expiresAt,
id: aura.definition?.id,
utilityTags: aura.definition?.utilityTags ?? [],
}));
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: (() => {
const character = shellState.characters?.find((entry) => entry.id === shellState.selectedCharacterId);
return character ? {
id: character.id,
name: character.name,
classId: character.classId,
secondaryClassId: character.secondaryClassId ?? null,
level: character.level,
statRuleset: character.statRuleset ?? null,
categoryId: character.categoryId,
equipment: character.equipment ?? null,
inventory: character.inventory ?? null,
gearStats: character.gearStats ?? 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,
auras: active.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,
})),
bloodArrowAuras: activeBloodArrow.map((aura) => ({
instanceId: aura.instanceId,
name: aura.definition?.name,
targetId: aura.targetId,
sourceId: aura.sourceId,
expiresAt: aura.expiresAt,
id: aura.definition?.id,
utilityTags: aura.definition?.utilityTags ?? [],
})),
rawBloodArrowAuras: (state.auras ?? []).filter((aura) => (
aura.definition?.utilityTags?.includes('rom-self-health-drain')
|| aura.definition?.name === 'Blood Arrow'
)).map((aura) => ({
instanceId: aura.instanceId,
name: aura.definition?.name,
sourceId: aura.sourceId,
targetId: aura.targetId,
expiresAt: aura.expiresAt,
id: aura.definition?.id,
utilityTags: aura.definition?.utilityTags ?? [],
})),
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(-1000),
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,
})),
abilities: (state.abilities ?? []).map((ability) => ({
id: ability.id,
name: ability.name,
classId: ability.classId,
secondaryClassId: ability.secondaryClassId ?? null,
unlockLevel: ability.unlockLevel,
passive: ability.passive,
source: ability.source,
dbcSpellId: ability.dbcSpellId ?? null,
romSkillGroup: ability.romSkillGroup ?? null,
sourceAttackKind: ability.sourceAttackKind ?? null,
description: ability.description ?? '',
target: ability.target,
range: ability.range,
effects: ability.effects ?? [],
cost: ability.cost,
cooldownMs: ability.cooldownMs,
})),
equipment: state.equipment ?? null,
inventory: state.inventory ?? null,
gearStats: state.gearStats ?? null,
equipmentRelatedStateFields: Object.fromEntries(Object.entries(state)
.filter(([key]) => /equipment|inventory|gear/i.test(key))),
},
party: memberRows,
npcBloodArrowAuras,
dungeonRun: dungeonRun?.getState?.().progress ?? null,
};
}, PLAYER_ID);
}
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.stockadeBloodArrowStores = 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;
return {
getMobPosition: readTransform ? (id) => readTransform(id)?.position ?? null : 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 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,
`${fixture.name} fits the UI character-name field.`);
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 ({ name, classId, secondaryClassId }) => {
const { shell } = await window.stockadeBloodArrowStores();
const state = shell.getState();
const character = state.characters.find((entry) => entry.id === state.selectedCharacterId);
if (!character || character.name !== name || character.classId !== classId) {
throw new Error(`UI-created RoM profile ${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(secondaryClassId)) {
throw new Error(`${name} did not accept secondary class ${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,
};
}, fixture);
assert.equal(profile.classId, fixture.classId);
assert.equal(profile.secondaryClassId, fixture.secondaryClassId);
assert.equal(profile.level, 30);
assert.equal(profile.statRuleset, 'rom', 'UI-created RoM profile uses native RoM rules.');
await page.locator('.character-preview canvas').waitFor({ timeout: 180_000 });
await page.waitForTimeout(1_500);
const rosterScreenshot = await capture(page, `${fixture.key}-roster-level-30`);
return { profile, rosterScreenshot };
}
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 discoverBloodArrowAndRangedSkill(page, fixture) {
return page.evaluate(({ fixtureKey }) => window.stockadeBloodArrowStores().then(({ combat }) => {
const state = combat.getState();
const blood = state.abilities.find((ability) => ability.source === 'runewaker'
&& (ability.dbcSpellId ?? ability.sourceSkillId) === 490434
&& ability.classId === 'rom-scout'
&& ability.secondaryClassId == null
&& !ability.passive);
const flatten = (effects = []) => effects.flatMap((effect) => effect.kind === 'composite'
? flatten(effect.effects) : [effect]);
const candidates = state.abilities.filter((ability) => {
const effects = flatten(ability.effects ?? []);
if (ability.source !== 'runewaker' || ability.passive
|| (ability.dbcSpellId ?? ability.sourceSkillId) === 490434
|| (ability.dbcSpellId ?? ability.sourceSkillId) === 492589
|| ability.sourceAttackKind === 'melee'
|| !effects.some((effect) => effect.kind === 'damage' || effect.kind === 'dot')
|| !(/ranged weapon DPS/i.test(ability.description ?? '')
|| (ability.dbcSpellId ?? ability.sourceSkillId) === 490420)) return false;
if (fixtureKey === 'knight-scout') return (ability.classId === 'rom-scout'
&& ability.secondaryClassId == null && ability.romSkillGroup === 'general')
|| (ability.classId === 'rom-knight' && ability.secondaryClassId === 'rom-scout');
return ability.classId === 'rom-scout' && ability.secondaryClassId == null
&& ['general', 'primary'].includes(ability.romSkillGroup);
}).filter((ability) => ability.unlockLevel <= state.level)
.sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name));
const candidate = candidates[0] ?? null;
const safeBuff = state.abilities.filter((ability) => {
const effects = flatten(ability.effects ?? []);
return !ability.passive
&& ability.target === 'self'
&& ability.unlockLevel <= state.level
&& (ability.dbcSpellId ?? ability.sourceSkillId) !== 490434
&& effects.some((effect) => effect.kind === 'apply-aura' && effect.aura?.disposition === 'buff')
&& !effects.some((effect) => effect.kind === 'damage' || effect.kind === 'dot' || effect.kind === 'control');
}).sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name))[0] ?? null;
return {
player: { classId: state.classId, secondaryClassId: state.secondaryClassId, level: state.level, statRuleset: state.statRuleset },
bloodArrow: blood ? { id: blood.id, name: blood.name, dbcSpellId: blood.dbcSpellId, unlockLevel: blood.unlockLevel, cooldownMs: blood.cooldownMs } : null,
rangedSkill: candidate ? {
id: candidate.id,
name: candidate.name,
source: candidate.source,
dbcSpellId: candidate.dbcSpellId ?? null,
unlockLevel: candidate.unlockLevel,
cooldownMs: candidate.cooldownMs,
castTimeMs: candidate.castTimeMs,
sourceAttackKind: candidate.sourceAttackKind ?? null,
range: candidate.range ?? null,
classId: candidate.classId,
secondaryClassId: candidate.secondaryClassId ?? null,
description: candidate.description ?? '',
} : null,
safeBuff: safeBuff ? {
id: safeBuff.id,
name: safeBuff.name,
dbcSpellId: safeBuff.dbcSpellId ?? safeBuff.sourceSkillId ?? null,
unlockLevel: safeBuff.unlockLevel,
cooldownMs: safeBuff.cooldownMs,
cost: safeBuff.cost,
effects: safeBuff.effects,
} : null,
candidates: candidates.map((ability) => ({ id: ability.id, name: ability.name, dbcSpellId: ability.dbcSpellId ?? null,
unlockLevel: ability.unlockLevel, classId: ability.classId, secondaryClassId: ability.secondaryClassId ?? null })),
};
}), { fixtureKey: fixture.key });
}
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 prepareActionBindings(page, fixture, abilities) {
if (!abilities.bloodArrow) throw new Error(`${fixture.name} has no current source Blood Arrow ability (490434).`);
await bindAbilityWithUi(page, abilities.bloodArrow, 'face-bottom');
if (abilities.rangedSkill) await bindAbilityWithUi(page, abilities.rangedSkill, 'face-left');
if (abilities.safeBuff) await bindAbilityWithUi(page, abilities.safeBuff, 'face-top');
const snapshot = await liveSnapshot(page);
assert.equal(snapshot.player.actionBindings['primary:face-bottom'], abilities.bloodArrow.id,
'Blood Arrow was assigned through the rendered ability controls UI.');
if (abilities.rangedSkill) assert.equal(snapshot.player.actionBindings['primary:face-left'], abilities.rangedSkill.id,
'The eligible ranged skill was assigned through the rendered ability controls UI.');
if (abilities.safeBuff) assert.equal(snapshot.player.actionBindings['primary:face-top'], abilities.safeBuff.id,
'A safe existing self buff was assigned through the rendered ability controls UI.');
if (snapshot.game.overlay === 'pause') await page.getByRole('button', { name: 'Resume game', exact: true }).click();
return snapshot.player.actionBindings;
}
async function activateActionSlot(page, control) {
const slot = page.locator(`.action-slot--${control}`);
await slot.waitFor({ state: 'visible', timeout: 10_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',
`The rendered ${control} action slot is available for ordinary UI input.`);
await slot.click();
}
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;
}
function distance3(from, to) {
return Math.hypot(from[0] - to[0], from[1] - to[1], from[2] - to[2]);
}
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 enterStockade(page, fixture, expectedRole) {
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--${expectedRole}`);
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.stockadeBloodArrowStores().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, expectedRole);
assert.equal(entry.party.length, 4);
assert(entry.party.every((member) => member.statRuleset === 'rom'));
const actualParty = entry.party.map(stableIdentity).sort();
let partyCompositionAssessment;
if (fixture.key === 'knight-scout') {
assert.deepEqual(actualParty, partyIdentitiesFor(fixture),
'The primary Knight/Scout fixture uses the other four identities from romFiveIdentities.');
partyCompositionAssessment = 'Exact native-five complement verified.';
} else {
partyCompositionAssessment = actualParty.join(',') === partyIdentitiesFor(fixture).join(',')
? 'Exact native-five complement verified.'
: 'Optional HP-only fixture uses the ordinary generated RoM damage-role party; its companions are recorded, with no exact-complement claim.';
}
const evidence = {
dungeonPicker: await capture(page, `${fixture.key}-stockade-picker`),
dungeonEntrance: await capture(page, `${fixture.key}-stockade-entrance-party`),
};
// Open the ordinary inventory UI at the entrance to record real player body
// and current source gear, then close it without changing anything.
await page.keyboard.press('Escape');
await page.getByRole('dialog', { name: 'Paused' }).waitFor({ state: 'visible', timeout: 10_000 });
await page.getByRole('button', { name: 'Inventory & equipment', exact: true }).click();
await page.getByRole('dialog', { name: 'Inventory & Equipment' }).waitFor({ state: 'visible', timeout: 30_000 });
evidence.bodyAndGear = await capture(page, `${fixture.key}-body-and-gear`);
const equipmentDialogText = await page.getByRole('dialog', { name: 'Inventory & Equipment' }).innerText();
await page.keyboard.press('Escape');
await page.getByRole('dialog', { name: 'Inventory & Equipment' }).waitFor({ state: 'detached', timeout: 10_000 });
await page.locator('.party-frames').waitFor();
return { entry, evidence, equipmentDialogText, partyCompositionAssessment };
}
async function waitForAura(page, abilityId, active) {
await page.waitForFunction(({ playerId, expectedActive }) => window.stockadeBloodArrowStores().then(({ combat }) => {
const state = combat.getState();
const aura = state.auras.some((entry) => entry.sourceId === playerId && entry.targetId === playerId
&& (entry.expiresAt === null || entry.expiresAt > Date.now())
&& (entry.definition?.utilityTags?.includes('rom-self-health-drain')
|| entry.definition?.name === 'Blood Arrow'));
return aura === expectedActive;
}), { playerId: PLAYER_ID, expectedActive: active }, { timeout: 15_000 }).catch(() => {});
const snapshot = await liveSnapshot(page);
if (active) {
assert(snapshot.player.bloodArrowAuras.length > 0,
`${abilityId} activation created an owned Blood Arrow aura. Raw aura state: ${JSON.stringify(snapshot.player.rawBloodArrowAuras)}; active player auras: ${JSON.stringify(snapshot.player.auras)}; cooldowns: ${JSON.stringify(snapshot.player.cooldowns)}; feedback: ${JSON.stringify(snapshot.player.feedback)}`);
assert.equal(snapshot.player.bloodArrowAuras[0].expiresAt, null, 'Blood Arrow remains active indefinitely.');
} else {
assert.equal(snapshot.player.bloodArrowAuras.length, 0, 'The owned Blood Arrow aura was removed.');
}
return snapshot;
}
async function rightClickDifferentBuff(page, fixture, safeBuff) {
if (!safeBuff) {
return { status: 'not_available', reason: 'No learned, non-damaging self-targeted aura buff was available in this level-30 profile.' };
}
await activateActionSlot(page, 'face-top');
await page.waitForFunction(({ playerId, name }) => window.stockadeBloodArrowStores().then(({ combat }) => {
const state = combat.getState();
return state.auras.some((aura) => aura.sourceId === playerId && aura.targetId === playerId
&& (aura.definition?.name === name || aura.definition?.name?.startsWith(`${name}:`))
&& (aura.expiresAt === null || aura.expiresAt > Date.now()));
}), { playerId: PLAYER_ID, name: safeBuff.name }, { timeout: 10_000 }).catch(() => {});
const before = await liveSnapshot(page);
const targetAura = before.player.auras.find((aura) => aura.name === safeBuff.name
|| aura.name?.startsWith(`${safeBuff.name}:`));
if (!targetAura) {
return {
status: 'cast_not_observed',
safeBuff,
bindings: before.player.actionBindings,
recentEvents: before.player.combatEvents.slice(-40),
feedback: before.player.feedback,
};
}
assert(before.player.bloodArrowAuras.length > 0,
'Blood Arrow remains active alongside the separate buff before context-menu input.');
const icons = page.locator('.player-status-panel__section--buffs .aura[aria-label]');
const ariaLabels = await icons.evaluateAll((elements) => elements.map((element) => element.getAttribute('aria-label') ?? ''));
const iconIndex = ariaLabels.findIndex((label) => label.startsWith(`${safeBuff.name}, buff`)
|| label.startsWith(`${safeBuff.name}:`));
if (iconIndex < 0) {
return { status: 'not_visible_in_player_buff_ui', safeBuff, ariaLabels, before };
}
await capture(page, `${fixture.key}-different-buff-and-blood-arrow`);
await icons.nth(iconIndex).click({ button: 'right' });
await page.waitForTimeout(250);
const after = await liveSnapshot(page);
assert(after.player.bloodArrowAuras.length > 0,
`Right-clicking the separate ${safeBuff.name} buff must leave Blood Arrow active.`);
return {
status: 'blood_arrow_preserved_after_other_buff_right_click',
safeBuff,
before: { bloodArrowAuras: before.player.bloodArrowAuras, auras: before.player.auras },
after: { bloodArrowAuras: after.player.bloodArrowAuras, auras: after.player.auras },
screenshot: await capture(page, `${fixture.key}-after-other-buff-right-click`),
};
}
async function tryNpcOwnerToggle(page, fixture) {
const before = await liveSnapshot(page);
const observed = before.npcBloodArrowAuras[0];
if (!observed) {
return {
status: 'not_observed',
reason: 'No owned NPC Blood Arrow aura was exposed by party telemetry in this bounded Stockade segment.',
partyAuras: before.npcBloodArrowAuras,
};
}
const owner = before.party.find((member) => member.id === observed.ownerId);
const frame = page.locator('.party-frame').filter({ hasText: owner?.name ?? observed.ownerId });
const icon = frame.locator('.aura[aria-label*="Blood Arrow"]').first();
if (!(await icon.count())) {
return {
status: 'not_visible_in_party_ui',
reason: 'Telemetry found the NPC aura, but its owner icon was not rendered in the party frame for ordinary right-click input.',
owner,
observed,
partyAuras: before.npcBloodArrowAuras,
};
}
await capture(page, `${fixture.key}-npc-blood-arrow-owner-buff`);
const otherOwnersBefore = before.npcBloodArrowAuras.filter((aura) => aura.ownerId !== observed.ownerId)
.map((aura) => `${aura.ownerId}:${aura.instanceId}`).sort();
const npcOwnerIds = before.party.map((member) => member.id);
const beforeCooldowns = await readNpcBloodArrowCooldowns(page, npcOwnerIds);
await icon.click({ button: 'right' });
await page.waitForFunction(({ ownerId }) => window.stockadeBloodArrowStores().then(({ combat }) => {
const state = combat.getState();
return !state.auras.some((aura) => aura.sourceId === ownerId && aura.targetId === ownerId
&& (aura.expiresAt === null || aura.expiresAt > Date.now())
&& (aura.definition?.utilityTags?.includes('rom-self-health-drain')
|| aura.definition?.name === 'Blood Arrow'));
}), { ownerId: observed.ownerId }, { timeout: 8_000 });
const after = await liveSnapshot(page);
const afterCooldowns = await readNpcBloodArrowCooldowns(page, npcOwnerIds);
const actor = await page.evaluate((ownerId) => window.stockadeBloodArrowStores().then(({ combat }) => {
const state = combat.getState();
return state.actors?.[ownerId] ? {
cooldowns: state.actors[ownerId].cooldowns ?? null,
health: state.actors[ownerId].health,
maxHealth: state.actors[ownerId].maxHealth,
} : null;
}), observed.ownerId);
const otherOwnersAfter = after.npcBloodArrowAuras.filter((aura) => aura.ownerId !== observed.ownerId)
.map((aura) => `${aura.ownerId}:${aura.instanceId}`).sort();
const ownerCooldownReadyAt = Number(afterCooldowns[observed.ownerId] ?? 0);
assert.deepEqual(otherOwnersAfter, otherOwnersBefore, 'Right-clicking the NPC buff does not toggle another owner’s aura.');
assert(after.player.bloodArrowAuras.length > 0, 'Right-clicking the NPC buff leaves the player-owned Blood Arrow active.');
for (const member of before.party) {
if (member.id === observed.ownerId) continue;
assert.equal(afterCooldowns[member.id], beforeCooldowns[member.id],
`Right-clicking ${owner?.name ?? observed.ownerId}'s NPC aura leaves ${member.name}'s Blood Arrow cooldown unchanged.`);
}
assert(ownerCooldownReadyAt > after.observedAt,
'The clicked NPC owner entered Blood Arrow deactivation cooldown after the aura icon was right-clicked.');
assert(ownerCooldownReadyAt - after.observedAt <= DEACTIVATION_COOLDOWN_MS + 750,
'The clicked NPC owner received an approximately 10-second Blood Arrow cooldown.');
const cooldownReadyAt = Number(actor?.cooldowns?.['rom-scout-general-4'] ?? 0);
const cooldownRemainingMs = cooldownReadyAt - after.observedAt;
assert.equal(cooldownReadyAt, ownerCooldownReadyAt,
'NPC actor state and owner cooldown telemetry agree for Blood Arrow.');
return {
status: 'removed_by_owner_right_click',
owner,
observed,
after: after.npcBloodArrowAuras,
actor,
beforeCooldowns,
afterCooldowns,
cooldownRemainingMs,
otherOwnersBefore,
otherOwnersAfter,
screenshot: await capture(page, `${fixture.key}-npc-blood-arrow-owner-off`),
};
}
async function readNpcBloodArrowCooldowns(page, ownerIds) {
return page.evaluate(({ ownerIds, abilityId }) => window.stockadeBloodArrowStores().then(({ combat }) => {
const actors = combat.getState().actors ?? {};
return Object.fromEntries(ownerIds.map((ownerId) => [ownerId,
actors[ownerId]?.cooldowns?.[abilityId] ?? null]));
}), { ownerIds, abilityId: 'rom-scout-general-4' });
}
async function castAndMeasureRangedSkill(page, fixture, ability) {
if (!ability) return { status: 'not_observable', reason: 'No eligible learned ranged weapon skill was available in this level-30 profile.' };
const start = await liveSnapshot(page);
await page.keyboard.press('Tab');
await page.waitForFunction(() => window.stockadeBloodArrowStores().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 targetState = await liveSnapshot(page);
const target = targetState.player.selectedTargetId
? targetState.player.selectedTargetId
: null;
if (!target) return { status: 'not_observable', reason: 'Tab did not select a live Stockade target.', before: start };
const approach = await approachRangedTarget(page, target, ability.range?.max ?? 6);
if (!approach.withinRange) {
return { status: 'target_out_of_range', ability, selectedTargetId: target, approach,
reason: 'Ordinary WASD movement did not bring the selected target inside the skill range.' };
}
const preCast = await liveSnapshot(page);
const previousEventId = Math.max(0, ...preCast.player.combatEvents.map((event) => Number(event.id) || 0));
await activateActionSlot(page, 'face-left');
await page.waitForTimeout(Math.min(ability.castTimeMs ?? 0, 8_000) + 1_200);
const after = await liveSnapshot(page);
const newEvents = after.player.combatEvents.filter((event) => (Number(event.id) || 0) > previousEventId);
const castEvents = newEvents.filter((event) => event.abilityId === ability.id
&& (event.sourceActorId === PLAYER_ID || event.sourceActorId === 'player'));
const playerResourceEvents = newEvents.filter((event) => event.sourceActorId === PLAYER_ID
|| event.targetActorId === PLAYER_ID);
const hpCostExpected = Math.max(0, Math.round(preCast.player.maxHealth * 0.02));
const maxMana = Number(preCast.player.maxResourcePools?.mana ?? preCast.player.maxMana
?? (preCast.player.resourceType === 'mana' ? preCast.player.maxResource : 0));
const mpCostExpected = fixture.key === 'knight-scout' ? Math.max(0, Math.round(maxMana * 0.01)) : 0;
const result = {
status: castEvents.length ? 'accepted_event_observed' : 'attempted_without_matching_cast_event',
ability,
approach,
selectedTargetId: target,
before: { health: preCast.player.health, maxHealth: preCast.player.maxHealth, resource: preCast.player.resource,
maxResource: preCast.player.maxResource, maxResourcePools: preCast.player.maxResourcePools,
cooldownReadyAt: preCast.player.cooldowns?.[ability.id] ?? 0 },
after: { health: after.player.health, maxHealth: after.player.maxHealth, resource: after.player.resource,
maxResource: after.player.maxResource, maxResourcePools: after.player.maxResourcePools,
cooldownReadyAt: after.player.cooldowns?.[ability.id] ?? 0 },
expectedCastCosts: { hp: hpCostExpected, mana: mpCostExpected,
manaCostApplies: fixture.key === 'knight-scout' },
castEvents,
playerResourceEvents,
beforeSnapshot: preCast,
afterSnapshot: after,
accountingLimit: 'Report numeric resource deltas as inconclusive if combat damage, party healing, or regeneration overlaps the cast; matching runtime events are retained verbatim.',
};
const candidateCast = castEvents.some((event) => event.kind === 'ability' || event.kind === 'cast'
|| event.kind === 'damage' || event.kind === 'dot' || event.kind === 'cast-complete');
if (candidateCast) result.status = 'accepted_event_observed';
await capture(page, `${fixture.key}-after-ranged-skill`);
return result;
}
async function idleAndInspect(page, fixture, bloodArrow) {
const before = await liveSnapshot(page);
const startedAt = Date.now();
const samples = [];
while (Date.now() - startedAt < 31_000) {
await page.waitForTimeout(1_000);
const snapshot = await liveSnapshot(page);
samples.push({
wallElapsedMs: Date.now() - startedAt,
game: snapshot.game,
health: snapshot.player.health,
maxHealth: snapshot.player.maxHealth,
resource: snapshot.player.resource,
maxResource: snapshot.player.maxResource,
bloodArrowAuras: snapshot.player.bloodArrowAuras,
effects: snapshot.player.effects,
mobs: snapshot.player.mobs.map((mob) => ({ id: mob.id, health: mob.health, dead: mob.dead, engaged: mob.engaged })),
combatEvents: snapshot.player.combatEvents,
});
}
const after = await liveSnapshot(page);
const allEvents = after.player.combatEvents;
const newEvents = allEvents.filter((event) => (Number(event.occurredAt ?? event.at ?? event.timestamp) || 0) >= startedAt);
const playerSelfDamage = newEvents.filter((event) => event.sourceActorId === PLAYER_ID
&& event.targetActorId === PLAYER_ID
&& event.abilityId === bloodArrow.id
&& event.kind === 'damage');
const incomingDamage = newEvents.filter((event) => event.kind === 'damage'
&& event.targetActorId === PLAYER_ID && event.sourceActorId !== PLAYER_ID);
const playerResourceEvents = newEvents.filter((event) => event.kind === 'resource'
&& event.sourceActorId === PLAYER_ID && event.targetActorId === PLAYER_ID);
const bloodArrowOwnedEffects = [...before.player.effects, ...after.player.effects].filter((effect) => (
effect.sourceActorId === PLAYER_ID
&& effect.targetId === PLAYER_ID
&& (effect.sourceAbilityId?.includes('blood-arrow') || effect.id?.includes('blood-arrow'))
));
assert(after.observedAt - before.observedAt >= 30_000, 'Idle observation lasted more than 30 seconds.');
assert(after.player.bloodArrowAuras.length > 0, 'Blood Arrow remained active through the 30-second idle observation.');
assert.deepEqual(playerSelfDamage, [], 'No player-owned Blood Arrow self-damage event occurred while idle.');
await capture(page, `${fixture.key}-blood-arrow-idle-over-30-seconds`);
return {
durationMs: after.observedAt - before.observedAt,
samples,
playerSelfDamage,
incomingDamage,
playerResourceEvents,
bloodArrowOwnedEffects,
eventWindow: { startedAt, endedAt: after.observedAt, observedEventCount: newEvents.length },
};
}
async function deactivateByRightClick(page, fixture, bloodArrow) {
const selector = page.locator('.player-status-panel__section--buffs .aura').filter({ hasText: /Blood Arrow/i });
const fallback = page.locator('.player-status-panel__section--buffs .aura[aria-label*="Blood Arrow"]');
let icon = selector.first();
if (!(await icon.count())) icon = fallback.first();
await icon.waitFor({ state: 'visible', timeout: 10_000 });
await icon.click({ button: 'right' });
const off = await waitForAura(page, bloodArrow.id, false);
const cooldownReadyAt = Number(off.player.cooldowns?.[bloodArrow.id] ?? 0);
assert(cooldownReadyAt > off.observedAt, 'Right-click deactivation started a cooldown.');
assert(cooldownReadyAt - off.observedAt <= DEACTIVATION_COOLDOWN_MS + 750,
'Right-click deactivation cooldown is approximately 10 seconds.');
return { off, cooldownReadyAt, screenshot: await capture(page, `${fixture.key}-right-click-off-cooldown`) };
}
async function rejectActivationOnCooldown(page, fixture, bloodArrow) {
const before = await liveSnapshot(page);
const previousEventId = Math.max(0, ...before.player.combatEvents.map((event) => Number(event.id) || 0));
await page.keyboard.press('1');
await page.waitForTimeout(350);
const after = await liveSnapshot(page);
const newEvents = after.player.combatEvents.filter((event) => (Number(event.id) || 0) > previousEventId);
const matchingCastEvents = newEvents.filter((event) => event.abilityId === bloodArrow.id
&& (event.sourceActorId === PLAYER_ID || event.sourceActorId === 'player'));
assert.equal(after.player.bloodArrowAuras.length, 0, 'Cooldown activation attempt did not restore Blood Arrow.');
assert.deepEqual(matchingCastEvents, [], 'Cooldown activation attempt produced no accepted Blood Arrow cast event.');
return {
before: { health: before.player.health, resource: before.player.resource, pools: before.player.resourcePools },
after: { health: after.player.health, resource: after.player.resource, pools: after.player.resourcePools },
matchingCastEvents,
cooldownReadyAt: before.player.cooldowns?.[bloodArrow.id] ?? null,
feedback: after.player.feedback,
screenshot: await capture(page, `${fixture.key}-cooldown-activation-rejected`),
accountingLimit: 'Immediate resource deltas may include concurrent NPC damage or natural regeneration; the no-aura/no-cast-event check is the rejection proof.',
};
}
async function waitForCooldownThenReactivate(page, fixture, bloodArrow) {
await page.waitForFunction(({ playerId, abilityId }) => window.stockadeBloodArrowStores().then(({ combat }) => {
const state = combat.getState();
const readyAt = state.cooldowns?.[abilityId] ?? 0;
const aura = state.auras.some((entry) => entry.sourceId === playerId && entry.targetId === playerId
&& (entry.expiresAt === null || entry.expiresAt > Date.now())
&& (entry.definition?.utilityTags?.includes('rom-self-health-drain')
|| entry.definition?.name === 'Blood Arrow'));
return Date.now() >= readyAt && !aura;
}), { playerId: PLAYER_ID, abilityId: bloodArrow.id }, { timeout: 12_000 });
const before = await liveSnapshot(page);
await activateActionSlot(page, 'face-bottom');
const active = await waitForAura(page, bloodArrow.id, true);
return { before, active, screenshot: await capture(page, `${fixture.key}-reactivated-after-cooldown`) };
}
async function reuseToDeactivate(page, fixture, bloodArrow) {
const before = await liveSnapshot(page);
await activateActionSlot(page, 'face-bottom');
const off = await waitForAura(page, bloodArrow.id, false);
const readyAt = Number(off.player.cooldowns?.[bloodArrow.id] ?? 0);
assert(readyAt > off.observedAt, 'Using Blood Arrow again while active deactivates it and starts its cooldown.');
return { before, off, screenshot: await capture(page, `${fixture.key}-reuse-deactivated`) };
}
async function exercisePlayerFixture(page, fixture) {
await selectProfile(page, fixture);
const expectedRole = fixture.key === 'knight-scout' ? 'tank' : 'damage';
const { entry, evidence, equipmentDialogText, partyCompositionAssessment } = await enterStockade(page, fixture, expectedRole);
const discoveries = await discoverBloodArrowAndRangedSkill(page, fixture);
if (!discoveries.bloodArrow) {
throw new Error(`${fixture.name} Blood Arrow source row 490434 is not learned at level 30 in the initialized combat runtime.`);
}
const bindings = await prepareActionBindings(page, fixture, discoveries);
const screenshots = { ...evidence };
// Some RoM combat starts auto-enable this indefinite toggle. Preserve that
// observed entry state; otherwise activate it through the rendered action bar.
const activationBefore = await liveSnapshot(page);
const activation = activationBefore.player.bloodArrowAuras.length > 0
? {
status: 'already_active_before_direct_input',
note: 'The owned Blood Arrow toggle was active at the pre-cast snapshot; no input was sent here because the same action deactivates it.',
snapshot: activationBefore,
}
: await (async () => {
await activateActionSlot(page, 'face-bottom');
return { status: 'activated_by_rendered_action_bar', snapshot: await waitForAura(page, discoveries.bloodArrow.id, true) };
})();
assert(activation.snapshot.player.bloodArrowAuras.length > 0,
'Blood Arrow is active on the player before the toggle/cancel checks.');
assert.equal(activation.snapshot.player.bloodArrowAuras[0].expiresAt, null,
'Blood Arrow remains active indefinitely.');
screenshots.activated = await capture(page, `${fixture.key}-blood-arrow-active`);
const buffIcon = page.locator('.player-status-panel__section--buffs .aura[aria-label*="Blood Arrow"]').first();
if (await buffIcon.count()) {
await buffIcon.hover();
await page.getByRole('tooltip').waitFor({ state: 'visible', timeout: 3_000 }).catch(() => {});
screenshots.buffContext = await capture(page, `${fixture.key}-blood-arrow-buff-context`);
await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur());
}
const differentBuffRightClick = await rightClickDifferentBuff(page, fixture, discoveries.safeBuff);
let rangedSkill = { status: 'not_applicable' };
if (discoveries.rangedSkill) {
rangedSkill = await castAndMeasureRangedSkill(page, fixture, discoveries.rangedSkill);
screenshots.rangedSkill = `${fixture.key}-after-ranged-skill.png`;
}
const npcOwnerToggle = fixture.key === 'knight-scout'
? await tryNpcOwnerToggle(page, fixture)
: { status: 'not_attempted_on_secondary_fixture' };
let idle = null;
let rightClick = null;
let cooldownRejection = null;
let reactivation = null;
let reuseOff = null;
if (fixture.key === 'knight-scout') {
idle = await idleAndInspect(page, fixture, discoveries.bloodArrow);
rightClick = await deactivateByRightClick(page, fixture, discoveries.bloodArrow);
cooldownRejection = await rejectActivationOnCooldown(page, fixture, discoveries.bloodArrow);
reactivation = await waitForCooldownThenReactivate(page, fixture, discoveries.bloodArrow);
reuseOff = await reuseToDeactivate(page, fixture, discoveries.bloodArrow);
} else {
// The secondary profile is a targeted HP-only ordinary-cast check. It does
// not become a second full Blood Arrow toggle certification.
if (discoveries.rangedSkill) {
assert.equal(rangedSkill.ability?.source, 'runewaker');
}
}
const final = await liveSnapshot(page);
return {
profile: fixture,
role: expectedRole,
enteredThrough: 'Dungeons → Bite-Sized → Stormwind Stockade: Cellblock Rush, normal content download/role flow; seeded one-boss run, no Boss Testing.',
scope: fixture.key === 'knight-scout'
? 'Targeted level-30 Knight/Scout Blood Arrow toggle, idle, deactivation cooldown and eligible offensive ranged cast diagnostic. Needs Testing only.'
: 'Optional targeted level-30 Scout/Rogue ordinary ranged cast diagnostic for HP-only cost. Needs Testing only.',
evidence: { screenshots, equipmentDialogText },
discoveredAbilities: discoveries,
actionBindings: bindings,
partyCompositionAssessment,
entry,
activation,
activationBefore,
differentBuffRightClick,
rangedSkill,
idle,
rightClick,
cooldownRejection,
reactivation,
reuseOff,
npcOwnerToggle,
final,
limitations: [
'No player AutoShot timer was added or simulated; existing player ongoing AutoShot is not evidence for the per-cast cost.',
'No forced mob health, staged cast, in-combat profile, weapon, health, mana, or cooldown edits were made.',
'Any resource delta overlapping NPC attacks, party healing, or regeneration is inconclusive; raw actual-dist events and samples are preserved.',
'The level-70 Knight/Scout rider is outside this level-30 player fixture; source unit coverage remains the evidence for that condition.',
'NPC-owner aura and scheduled ranged-auto health cost are recorded only if the ordinary party AI exposes those events in this bounded Stockade segment.',
],
};
}
(async () => {
if (moduleFiles.length < 3) throw new Error(`Expected final built game chunks are missing from dist/assets: ${moduleFiles.join(', ')}`);
for (const expectedAsset of EXPECTED_FINAL_BUILD_ASSETS) {
assert(moduleFiles.includes(expectedAsset), `Fresh corrected build asset is present: ${expectedAsset}`);
}
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir: dataDirectory, staticDir: path.resolve('dist'), 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',
buildAssets: moduleFiles,
dungeon: { id: DEFAULT_TEST_DUNGEON_ID, title: DEFAULT_TEST_DUNGEON_TITLE },
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.startsWith('/assets/') && url.pathname.endsWith('.js')) {
servedBuildAssets.push({ status: response.status(), path: url.pathname });
}
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();
const profileEvidence = [];
for (const fixture of FIXTURES) profileEvidence.push(await createProfile(page, fixture));
run.rosterProfiles = profileEvidence;
run.fixtures.push(await exercisePlayerFixture(page, FIXTURES[0]));
// One additional live fixture for the independently owned Scout/Rogue
// HP-only cast path; the run remains two bounded Stockade segments.
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();
run.fixtures.push(await exercisePlayerFixture(page, FIXTURES[1]));
for (const expectedAsset of EXPECTED_FINAL_BUILD_ASSETS) {
assert(servedBuildAssets.some((entry) => entry.path === `/assets/${expectedAsset}` && entry.status === 200),
`Fresh corrected build asset was served successfully: ${expectedAsset}`);
}
assert.deepEqual(pageErrors, [], `No browser page errors: ${JSON.stringify(pageErrors)}`);
run.status = 'complete';
fs.writeFileSync(path.join(outputDirectory, 'results.json'), `${JSON.stringify(run, null, 2)}\n`);
console.log(`Stockade Blood Arrow targeted live proof completed: ${outputDirectory}`);
} catch (error) {
run.status = 'failed';
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);
fs.writeFileSync(path.join(outputDirectory, 'failure.json'), `${JSON.stringify(run, null, 2)}\n`);
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-stockade-blood-arrow-')) {
throw new Error('Unsafe temporary playtest database cleanup path.');
}
fs.rmSync(target, { recursive: true, force: true });
}
})().catch((error) => { console.error(error); process.exitCode = 1; });