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