1118 lines
57 KiB
JavaScript
1118 lines
57 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 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; });
|