// Historical Barren/Lady evidence harness. Routine live QA uses Stockade. if (!process.argv.includes('--historical-wailing') && !process.argv.includes('--fixtures-only')) { throw new Error('Historical Lady live checks require an explicit owner request and --historical-wailing. Use the Stockade campaign/mechanics harness for new routine live QA.'); } 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'); // Isolated production QA: selected bosses, normal scene navigation and combat. // This script never writes readiness reviews or sends dungeon reports. const fullFightsOnly = process.argv.includes('--full-fights'); if (fullFightsOnly && process.argv.includes('--fixtures-only')) { throw new Error('--full-fights and --fixtures-only are separate QA modes.'); } const baseDirectory = path.resolve('playtest-artifacts/native-combat'); const fullFightRunId = new Date().toISOString().replace(/[-:.]/g, '').replace('T', '-').replace('Z', ''); const directory = fullFightsOnly ? path.join(baseDirectory, `full-fights-${fullFightRunId}`) : baseDirectory; const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-native-combat-')); const fixturesOnly = process.argv.includes('--fixtures-only'); const moduleUrls = fs.readdirSync('dist/assets') .filter((file) => /^(?:index|GameRuntime|combatStore|partyStore|PartyFrames)-.*\.js$/.test(file)) .map((file) => `/assets/${file}`); fs.mkdirSync(directory, { recursive: true }); const NATIVE_HEALER_PARTY = [ 'rom-knight:rom-scout:tank', 'rom-rogue:rom-warrior:damage', 'rom-scout:rom-rogue:damage', 'rom-mage:rom-warlock:damage', ].sort(); function positionDistance(left, right) { if (!left || !right) return Number.POSITIVE_INFINITY; return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]); } async function runNativeHealerFullFight(page, bossName, outputDirectory) { await page.locator('.main-menu-screen').waitFor(); await page.getByRole('button', { name: /^GM Menu/ }).click(); await page.getByRole('button', { name: /Boss Testing/ }).click(); const picker = page.getByLabel(/Choose a boss/); await picker.waitFor({ state: 'visible' }); await page.waitForFunction(() => document.querySelector('.boss-testing-picker select')?.options.length > 1); const option = await picker.locator('option').evaluateAll((options, name) => options.find((entry) => entry.textContent.startsWith(`${name} ·`))?.value, bossName); assert(option, `${bossName} selectable`); await picker.selectOption(option); const healerButton = page.getByRole('button', { name: 'Healer', exact: true }); if (await healerButton.count()) await healerButton.click(); await page.getByRole('button', { name: 'Test selected boss', exact: true }).click(); await page.locator('.party-frames').waitFor(); await page.locator('.loading-screen').waitFor({ state: 'detached' }); await page.evaluate(async () => { const { game } = await window.nativeCombatStores(); game.getState().closeOverlay(); }); await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached' }); await page.waitForFunction((name) => window.nativeCombatStores().then(({ game, combat }) => game.getState().assetStatus === 'ready' && Object.values(combat.getState().mobs).some((mob) => mob.boss && mob.name === name && mob.homePosition)), bossName); const setup = await page.evaluate((name) => { const stores = window.nativeCombatStores(); return stores.then(({ game, shell, combat, party }) => { const state = combat.getState(); const members = party.getState().members; const mob = Object.values(state.mobs).find((candidate) => candidate.boss && candidate.name === name && candidate.homePosition); if (!mob) throw new Error(`${name} boss record is missing.`); const effectsFor = (ability) => (ability.effects ?? []).flatMap((effect) => effect.kind === 'composite' ? effect.effects : [effect]); const available = state.abilities.filter((ability) => !ability.passive && (ability.unlockLevel ?? 1) <= state.level); const sorted = (preferredNames, predicate) => [ ...preferredNames.map((name) => available.find((ability) => ability.name === name && predicate(ability))).filter(Boolean), ...available.filter((ability) => !preferredNames.includes(ability.name) && predicate(ability)), ]; const bindFirstValid = (control, candidates) => { for (const ability of candidates) { if (state.setActionBinding('primary', control, ability.id)) return ability; } state.setActionBinding('primary', control, null); return null; }; const damage = bindFirstValid('face-bottom', sorted(['Holy Strike'], (ability) => ability.target === 'hostile' && effectsFor(ability).some((effect) => effect.kind === 'damage'))); const heal = bindFirstValid('face-left', sorted(['Urgent Heal', 'Heal'], (ability) => ability.target === 'friendly' && effectsFor(ability).some((effect) => effect.kind === 'heal'))); const groupHeal = bindFirstValid('face-top', sorted(['Group Heal'], (ability) => ability.target === 'friendly' && effectsFor(ability).some((effect) => effect.kind === 'heal' && effect.maxTargets))); const cleanse = bindFirstValid('face-right', sorted(['Cleanse'], (ability) => effectsFor(ability) .some((effect) => effect.kind === 'dispel' && effect.mode === 'cleanse'))); const interrupt = bindFirstValid('r1', sorted([], (ability) => ability.target === 'hostile' && effectsFor(ability).some((effect) => effect.kind === 'interrupt' || (effect.kind === 'status' && effect.status === 'interrupt')))); if (!damage || !heal) throw new Error(`Native healer actions missing for ${name}: ${JSON.stringify({ level: state.level, classId: state.classId, secondaryClassId: state.secondaryClassId, available: available.map((ability) => ability.name), })}`); const bindings = [ ['face-bottom', damage], ['face-left', heal], ['face-top', groupHeal], ['face-right', cleanse], ['r1', interrupt], ].map(([control, ability]) => ({ control, abilityId: ability?.id ?? null, abilityName: ability?.name ?? null, bound: true, })); return { boss: { id: mob.id, name: mob.name, level: mob.level, sourceNpcId: mob.runewakerNpcId ?? null, serverEntry: mob.serverEntry, maxHealth: mob.maxHealth, attacks: mob.attacks.map((attack) => ({ id: attack.id, name: attack.name, spellId: attack.spellId, attackKind: attack.attackKind, target: attack.target, school: attack.school, range: attack.range, castTimeMs: attack.castTimeMs, initialCooldownMs: attack.initialCooldownMs, repeatCooldownMs: attack.repeatCooldownMs, interruptible: attack.interruptible, telegraph: attack.telegraph, effect: attack.effect })), }, player: { classId: state.classId, secondaryClassId: state.secondaryClassId, statRuleset: state.statRuleset, level: state.level, role: shell.getState().activeDungeonRole, position: state.playerPosition, }, party: members.map((member) => ({ id: member.id, name: member.name, classId: member.classId, secondaryClassId: member.secondaryClassId, role: member.role, statRuleset: member.statRuleset, health: member.health, maxHealth: member.maxHealth, resource: member.resource, maxResource: member.maxResource, resourceType: member.resourceType, resourceName: member.resourceName })), bindings, actionPlan: { damage: damage.name, singleHeal: heal.name, groupHeal: groupHeal?.name ?? null, cleanse: cleanse?.name ?? null, interrupt: interrupt?.name ?? null, }, selectedTargetId: state.selectedTargetId, bossPosition: mob.homePosition, }; }); }, bossName); assert.equal(setup.player.classId, 'rom-priest', `${bossName} uses a real native Priest player`); assert.equal(setup.player.secondaryClassId, 'rom-knight', `${bossName} uses the active Priest/Knight pair`); assert.equal(setup.player.statRuleset, 'rom', `${bossName} player remains on native stats`); assert.equal(setup.player.role, 'healer', `${bossName} player role is healer`); assert.equal(setup.party.length, 4, `${bossName} has four live native companions`); assert.deepEqual(setup.party.map((member) => `${member.classId}:${member.secondaryClassId}:${member.role}`).sort(), NATIVE_HEALER_PARTY, `${bossName} uses the other four requested native identities`); assert(setup.party.every((member) => member.statRuleset === 'rom'), `${bossName} companions retain native stats`); if (bossName === 'Anglo') assert.equal(setup.boss.sourceNpcId, 101462, 'Anglo keeps raw source NPC identity'); if (bossName === 'Lady Anacondra') { assert.equal(setup.boss.serverEntry, 3671, 'Lady Anacondra remains the WoW 3.3.5 creature 3671'); assert.equal(setup.boss.sourceNpcId, null, 'WoW Lady Anacondra is not mislabeled as a Runewaker NPC'); assert(setup.boss.attacks.some((attack) => attack.name === 'Lightning Bolt'), 'Lady Anacondra has source Lightning Bolt'); assert(setup.boss.attacks.some((attack) => attack.name === "Druid's Slumber"), 'Lady Anacondra has source Druid’s Slumber'); } const slug = bossName.replaceAll(' ', '-'); await page.screenshot({ path: path.join(outputDirectory, `${slug}-full-fight-start.png`) }); await page.evaluate(async () => { const { game } = await window.nativeCombatStores(); game.getState().toggleOverlay('inventory'); }); await page.getByRole('dialog', { name: 'Inventory & Equipment' }).waitFor({ state: 'visible' }); const equipmentUiText = await page.getByRole('dialog', { name: 'Inventory & Equipment' }).innerText(); await page.screenshot({ path: path.join(outputDirectory, `${slug}-equipment-in-dungeon.png`) }); await page.evaluate(async () => { const { game } = await window.nativeCombatStores(); game.getState().closeOverlay(); }); await page.getByRole('dialog', { name: 'Inventory & Equipment' }).waitFor({ state: 'detached' }); const playerActions = []; const samples = []; const warningScreenshots = new Set(); const movements = []; const controlApplications = new Map(); const cleanseObservations = []; let targetSelectionMethod = 'keyboard-tab'; let previousPosition = setup.player.position; let previousHealth = new Map(); let previousControl = new Map(); let lastActionAt = 0; let middleScreenshotTaken = false; let ended = null; const readState = async () => page.evaluate((name) => window.nativeCombatStores().then(({ game, combat, party }) => { const state = combat.getState(); const mob = Object.values(state.mobs).find((candidate) => candidate.boss && candidate.name === name && candidate.homePosition); if (!mob) throw new Error(`${name} boss disappeared from combat state.`); return { now: Date.now(), player: { health: state.health, maxHealth: state.maxHealth, resource: state.resource, maxResource: state.maxResource, resourceType: state.resourceType, resourceName: state.resourceName, controlledUntil: state.controlledUntil, controlMechanic: state.controlMechanic, position: game.getState().playerPosition, activeCast: state.activeCast?.abilityId ?? null, globalCooldownEndsAt: state.globalCooldownEndsAt }, cooldowns: state.cooldowns, boss: { id: mob.id, name: mob.name, level: mob.level, health: mob.health, maxHealth: mob.maxHealth, dead: mob.dead, diedAt: mob.diedAt ?? null, attackRevision: mob.attackRevision, abilityCastCounts: mob.abilityCastCounts, activeCast: mob.activeCast, targetActorId: mob.targetActorId, position: mob.position ?? mob.homePosition, homePosition: mob.homePosition, damageMultiplier: mob.damageMultiplier, sourceNpcId: mob.runewakerNpcId ?? null, serverEntry: mob.serverEntry, attackNames: mob.attacks.map((attack) => ({ id: attack.id, name: attack.name, spellId: attack.spellId, telegraph: attack.telegraph, effect: attack.effect })) }, party: party.getState().members.map((member) => ({ id: member.id, name: member.name, classId: member.classId, secondaryClassId: member.secondaryClassId, role: member.role, statRuleset: member.statRuleset, health: member.health, maxHealth: member.maxHealth, resource: member.resource, maxResource: member.maxResource, resourceType: member.resourceType, resourceName: member.resourceName, resourcePools: member.resourcePools, controlMechanic: member.controlMechanic, controlledUntil: member.controlledUntil, status: member.status, activeCast: member.activeCast?.abilityId ?? null })), auras: state.auras.filter((aura) => aura.sourceId === mob.id || aura.definition.disposition === 'debuff') .map((aura) => ({ id: aura.definition.id, name: aura.definition.name, targetId: aura.targetId, sourceId: aura.sourceId, disposition: aura.definition.disposition, dispelCategory: aura.definition.dispelCategory ?? null, expiresAt: aura.expiresAt })), selectedTargetId: state.selectedTargetId, events: state.combatEvents.slice(-80), feedback: state.feedback ? { kind: state.feedback.kind, message: state.feedback.message, abilityId: state.feedback.abilityId ?? null, targetId: state.feedback.targetId ?? null } : null, }; }), bossName); const selectBoss = async () => { for (let attempt = 0; attempt < 6; attempt += 1) { const selected = await page.evaluate((name) => window.nativeCombatStores().then(({ combat }) => { const state = combat.getState(); return Object.values(state.mobs).find((mob) => mob.boss && mob.name === name && mob.homePosition)?.id === state.selectedTargetId; }), bossName); if (selected) return; await page.keyboard.press('Tab'); await page.waitForTimeout(60); } await page.evaluate((name) => window.nativeCombatStores().then(({ combat }) => { const state = combat.getState(); const mob = Object.values(state.mobs).find((candidate) => candidate.boss && candidate.name === name && candidate.homePosition); if (!mob || !combat.getState().selectMob(mob.id)) throw new Error(`${name} target selection failed.`); }), bossName); targetSelectionMethod = 'store-target-selection-fallback'; }; await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur()); await selectBoss(); const engageRange = await page.evaluate((name) => window.nativeCombatStores().then(({ combat }) => { const state = combat.getState(); const mob = Object.values(state.mobs).find((candidate) => candidate.boss && candidate.name === name && candidate.homePosition); const ability = state.abilities.find((candidate) => candidate.id === state.actionBindings['primary:face-bottom']); return { distance: mob ? Math.hypot(state.playerPosition[0] - mob.homePosition[0], state.playerPosition[1] - mob.homePosition[1], state.playerPosition[2] - mob.homePosition[2]) : Infinity, range: ability?.range.max ?? 0 }; }), bossName); for (let attempt = 0; attempt < 4; attempt += 1) { await selectBoss(); await page.keyboard.press('1'); await page.waitForTimeout(650); const engaged = await page.evaluate((name) => window.nativeCombatStores().then(({ combat }) => { const mob = Object.values(combat.getState().mobs).find((candidate) => candidate.boss && candidate.name === name); return Boolean(mob?.engaged || mob?.attackRevision || mob?.health < mob?.maxHealth); }), bossName); playerActions.push({ time: Date.now(), key: '1', ability: setup.actionPlan.damage, target: bossName, reason: attempt === 0 ? 'start-the-selected-boss-with-a-real-player-cast' : 'retry-after-measured-movement', positionBefore: (await readState()).player.position, expectedRange: engageRange }); if (engaged) break; await page.keyboard.down('w'); await page.waitForTimeout(350); await page.keyboard.up('w'); await page.waitForTimeout(100); const moved = await readState(); movements.push({ time: moved.now, input: 'W', positionBefore: previousPosition, positionAfter: moved.player.position, bossPosition: moved.boss.position, distanceBefore: positionDistance(previousPosition, moved.boss.position), distanceAfter: positionDistance(moved.player.position, moved.boss.position) }); previousPosition = moved.player.position; } const started = await readState(); if (!started.boss.dead && started.boss.attackRevision === 0 && started.boss.health === started.boss.maxHealth) { throw new Error(`${bossName} did not engage through the native healer's selected player action: ${JSON.stringify({ selectedTargetId: started.selectedTargetId, playerActions, start: started, })}`); } const fightStartedAt = Date.now(); const maxDurationMs = 240_000; let lastSampleSecond = -1; let observedCastKey = null; let didEvadeCastKey = null; const firstPlan = await page.evaluate(() => window.nativeCombatStores().then(({ combat }) => { const state = combat.getState(); const byBinding = (control) => { const abilityId = state.actionBindings[`primary:${control}`]; return state.abilities.find((ability) => ability.id === abilityId) ?? null; }; return { damage: byBinding('face-bottom')?.id ?? null, singleHeal: byBinding('face-left')?.id ?? null, groupHeal: byBinding('face-top')?.id ?? null, cleanse: byBinding('face-right')?.id ?? null, interrupt: byBinding('r1')?.id ?? null, }; })); const decide = async () => page.evaluate((args) => window.nativeCombatStores().then(({ combat, party }) => { const state = combat.getState(); const mob = Object.values(state.mobs).find((candidate) => candidate.boss && candidate.name === args.bossName); if (!mob || mob.dead || state.health <= 0 || state.activeCast || state.globalCooldownEndsAt > Date.now()) return null; const now = Date.now(); const ready = (abilityId) => { if (!abilityId) return false; const ability = state.abilities.find((candidate) => candidate.id === abilityId); if (!ability || (state.cooldowns[abilityId] ?? 0) > now) return false; const resource = ability.cost?.resource === 'health' ? state.health : ability.cost?.resource === state.resourceType ? state.resource : state.resourcePools[ability.cost?.resource] ?? 0; return resource + Number.EPSILON >= (ability.cost?.amount ?? 0); }; const members = party.getState().members; const partyAuras = state.auras.filter((aura) => aura.sourceId === mob.id && aura.definition.disposition === 'debuff' && aura.definition.dispelCategory); const actors = [ { id: '__player__', name: 'Player', health: state.health, maxHealth: state.maxHealth, controlledUntil: state.controlledUntil, controlMechanic: state.controlMechanic }, ...members.map((member) => ({ id: member.id, name: member.name, health: member.health, maxHealth: member.maxHealth, controlledUntil: member.controlledUntil, controlMechanic: member.controlMechanic })), ]; const statusTarget = actors.find((actor) => (actor.controlledUntil > now && actor.controlMechanic) || partyAuras.some((aura) => aura.targetId === actor.id && aura.expiresAt > now && aura.definition.disposition === 'debuff' && Boolean(aura.definition.dispelCategory))); if (statusTarget && ready(args.actionPlan.cleanse)) return { action: 'cleanse', key: '4', abilityId: args.actionPlan.cleanse, targetActorId: statusTarget.id, targetName: statusTarget.name, reason: 'cleanse-observed-control-or-dispellable-debuff', }; if (mob.activeCast?.interruptible && mob.activeCast.completesAt > now + 250 && ready(args.actionPlan.interrupt)) return { action: 'interrupt', key: '5', abilityId: args.actionPlan.interrupt, targetActorId: null, targetName: mob.name, reason: 'interrupt-observed-interruptible-source-cast', sourceCast: { attackId: mob.activeCast.attackId, name: mob.activeCast.name, startedAt: mob.activeCast.startedAt, completesAt: mob.activeCast.completesAt }, }; const injured = actors.filter((actor) => actor.health > 0 && actor.maxHealth > 0 && actor.health / actor.maxHealth < 0.86).sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth); if (injured.length >= 2 && ready(args.actionPlan.groupHeal)) return { action: 'group-heal', key: '3', abilityId: args.actionPlan.groupHeal, targetActorId: injured[0].id, targetName: injured[0].name, reason: 'two-or-more-allies-below-86-percent', }; if (injured.length && ready(args.actionPlan.singleHeal)) return { action: 'single-heal', key: '2', abilityId: args.actionPlan.singleHeal, targetActorId: injured[0].id, targetName: injured[0].name, reason: 'lowest-health-ally-below-86-percent', }; if (ready(args.actionPlan.damage)) return { action: 'damage', key: '1', abilityId: args.actionPlan.damage, targetActorId: null, targetName: mob.name, reason: 'damage-filler-when-no-heal-or-interrupt-is-due', }; return null; }), { bossName, actionPlan: firstPlan }); while (Date.now() - fightStartedAt < maxDurationMs) { const current = await readState(); const second = Math.floor((current.now - fightStartedAt) / 1_000); if (second !== lastSampleSecond) { lastSampleSecond = second; samples.push({ elapsedSeconds: second, ...current }); const trackedActors = [ { id: '__player__', ...current.player }, ...current.party.map((member) => ({ id: member.id, ...member })), ]; for (const actor of trackedActors) { const prior = previousHealth.get(actor.id); if (prior && actor.health > prior.health && actor.health - prior.health > 0.01) { previousHealth.set(actor.id, { health: actor.health, maxHealth: actor.maxHealth }); } else { previousHealth.set(actor.id, { health: actor.health, maxHealth: actor.maxHealth }); } const activeDispels = current.auras.filter((aura) => aura.targetId === actor.id && aura.dispelCategory && (aura.expiresAt === null || aura.expiresAt > current.now)); const statusActive = Boolean(actor.controlMechanic && actor.controlledUntil > current.now) || activeDispels.length > 0; const wasControlled = previousControl.get(actor.id) ?? false; if (statusActive && !wasControlled) { const sourceAura = activeDispels.find((aura) => aura.sourceId === current.boss.id); controlApplications.set(actor.id, { appliedAt: current.now, expiresAt: sourceAura?.expiresAt ?? actor.controlledUntil ?? null, auraName: sourceAura?.name ?? activeDispels[0]?.name ?? null, dispelCategories: [...new Set(activeDispels.map((aura) => aura.dispelCategory))] }); } if (!statusActive && wasControlled) { const application = controlApplications.get(actor.id); const cleanseBeforeExpiry = playerActions.some((action) => action.action === 'cleanse' && action.targetActorId === actor.id && application && action.time <= current.now && (application.expiresAt === null || action.time < application.expiresAt)); cleanseObservations.push({ targetActorId: actor.id, observedAt: current.now, label: cleanseBeforeExpiry ? 'cleanse-input-before-source-expiry' : application?.expiresAt !== null && application && current.now >= application.expiresAt ? 'cleared-at-or-after-source-expiry' : 'control-cleared-before-expiry-without-cleanse-attribution', application }); } previousControl.set(actor.id, statusActive); } } const activeCast = current.boss.activeCast; if (activeCast) { const castKey = `${activeCast.attackId}:${activeCast.startedAt}`; if (castKey !== observedCastKey) { observedCastKey = castKey; const attack = current.boss.attackNames.find((candidate) => candidate.id === activeCast.attackId); if (attack && !warningScreenshots.has(castKey)) { warningScreenshots.add(castKey); await page.screenshot({ path: path.join(outputDirectory, `${slug}-warning-${warningScreenshots.size}.png`) }); } } if (activeCast.targetActorId === '__player__') { const attack = current.boss.attackNames.find((candidate) => candidate.id === activeCast.attackId); const telegraph = attack?.telegraph; const center = telegraph?.anchor === 'target' ? activeCast.targetPosition : activeCast.sourcePosition; const distance = positionDistance(current.player.position, center); const insideCircle = telegraph?.shape === 'circle' && distance <= (telegraph.radius ?? 0); if (insideCircle && didEvadeCastKey !== `${activeCast.attackId}:${activeCast.startedAt}` && activeCast.completesAt - current.now > 300) { didEvadeCastKey = `${activeCast.attackId}:${activeCast.startedAt}`; const before = current.player.position; await page.keyboard.down('s'); await page.waitForTimeout(400); await page.keyboard.up('s'); const after = await readState(); movements.push({ time: after.now, input: 'S', reason: 'move-out-of-observed-source-circle-telegraph', attackId: activeCast.attackId, before, after: after.player.position, center, distanceBefore: positionDistance(before, center), distanceAfter: positionDistance(after.player.position, center), movedOutsideObservedTelegraph: positionDistance(after.player.position, center) > (telegraph.radius ?? 0) }); await page.screenshot({ path: path.join(outputDirectory, `${slug}-warning-movement.png`) }); } } } if (!middleScreenshotTaken && current.boss.health <= current.boss.maxHealth * 0.5) { middleScreenshotTaken = true; await page.screenshot({ path: path.join(outputDirectory, `${slug}-mid-fight.png`) }); } if (current.boss.dead) { ended = 'boss-defeated'; break; } if (current.player.health <= 0 && current.party.every((member) => member.health <= 0)) { ended = 'party-defeated'; break; } if (Date.now() - lastActionAt >= 350) { const plan = await decide(); if (plan) { if (plan.targetActorId === '__player__') { await page.locator('.party-frame--player').click(); } else if (plan.targetActorId) { await page.locator('.party-frame').filter({ hasText: plan.targetName }).first().click(); } else { await selectBoss(); } const before = await readState(); await page.keyboard.press(plan.key); await page.waitForTimeout(100); const after = await readState(); const previousEventId = Math.max(0, ...before.events.map((event) => event.id)); const resolvedEvents = after.events.filter((event) => event.id > previousEventId && event.abilityId === plan.abilityId); const action = { time: after.now, ...plan, targetSelectionMethod, playerHealthBefore: before.player.health, playerHealthAfter: after.player.health, playerResourceBefore: before.player.resource, playerResourceAfter: after.player.resource, partyHealthBefore: before.party.map((member) => ({ id: member.id, health: member.health })), partyHealthAfter: after.party.map((member) => ({ id: member.id, health: member.health })), feedback: after.feedback, bossCastAfter: after.boss.activeCast?.name ?? null, bossCastRevisionAfter: after.boss.attackRevision, actionCooldownReadyAtBefore: before.cooldowns[plan.abilityId] ?? 0, actionCooldownReadyAtAfter: after.cooldowns[plan.abilityId] ?? 0, activePlayerCastAfter: after.player.activeCast, resolvedEvents, actionResolved: after.player.activeCast === plan.abilityId || resolvedEvents.length > 0 || after.feedback?.abilityId === plan.abilityId || (after.cooldowns[plan.abilityId] ?? 0) > (before.cooldowns[plan.abilityId] ?? 0) }; playerActions.push(action); if (plan.action === 'interrupt') { const later = await page.waitForTimeout(100).then(readState); action.interruptObserved = !later.boss.activeCast || later.boss.activeCast.attackId !== plan.sourceCast.attackId; action.sourceCastEndedBeforeScheduledCompletion = action.interruptObserved && later.now < plan.sourceCast.completesAt; } lastActionAt = Date.now(); } } await page.waitForTimeout(300); } if (!ended) ended = 'time-limit'; const finalState = await readState(); const result = { encounter: bossName, testMode: 'full Boss Testing scene; original boss attacks/cooldowns retained', outcome: ended, clearTimeSeconds: ended === 'boss-defeated' ? (finalState.boss.diedAt - fightStartedAt) / 1_000 : null, durationSeconds: (finalState.now - fightStartedAt) / 1_000, targetSelectionMethod, playerActionPolicy: { controller: 'scripted keyboard input through the real primary action bindings; no direct damage/heal/resource/health edits', priority: ['interrupt a live interruptible boss cast', 'cleanse an observed control/dispel effect', 'Group Heal at 2+ allies below 86%', 'Urgent Heal/Heal on lowest ally below 86%', 'Holy Strike or first available damage ability'], bindings: setup.bindings, }, initial: setup, equipmentUiText, initialEngageRange: engageRange, playerActions, movements, controlApplications: [...controlApplications.entries()].map(([targetActorId, value]) => ({ targetActorId, ...value })), cleanseObservations, interruptsObserved: playerActions.filter((action) => action.action === 'interrupt'), warningScreenshots: [...warningScreenshots], samples, final: finalState, minima: { playerHealth: Math.min(...samples.map((sample) => sample.player.health), finalState.player.health), playerResource: Math.min(...samples.map((sample) => sample.player.resource), finalState.player.resource), partyHealth: Object.fromEntries(setup.party.map((member) => [member.id, Math.min(...samples.flatMap((sample) => sample.party.filter((candidate) => candidate.id === member.id) .map((candidate) => candidate.health)), finalState.party.find((candidate) => candidate.id === member.id)?.health ?? 0)])), partyResource: Object.fromEntries(setup.party.map((member) => [member.id, Math.min(...samples.flatMap((sample) => sample.party.filter((candidate) => candidate.id === member.id) .map((candidate) => candidate.resource)), finalState.party.find((candidate) => candidate.id === member.id)?.resource ?? 0)])), }, }; await page.screenshot({ path: path.join(outputDirectory, `${slug}-${ended}.png`) }); fs.writeFileSync(path.join(outputDirectory, `${slug}-full-fight.json`), `${JSON.stringify(result, null, 2)}\n`); assert.equal(ended, 'boss-defeated', `${bossName} completes as a full player-controlled fight`); assert(playerActions.some((action) => action.action === 'damage' && action.actionResolved), `${bossName} resolves player keyboard damage actions`); assert(playerActions.some((action) => (action.action === 'single-heal' || action.action === 'group-heal') && action.actionResolved), `${bossName} uses player keyboard healing actions`); assert(finalState.party.every((member) => member.statRuleset === 'rom'), `${bossName} ends with native companions`); return result; } (async () => { const { createGameServer } = await import('../server/server.mjs'); const runtime = createGameServer({ dataDir, enableShelvedModes: false }); await new Promise((resolve) => runtime.server.listen(4196, '127.0.0.1', resolve)); const browser = await chromium.launch({ channel: 'msedge', headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); page.setDefaultTimeout(180_000); const errors = []; const samples = []; const fixtureSamples = []; page.on('pageerror', (error) => errors.push(error.message)); await page.addInitScript((args) => { window.nativeCombatStores = async () => { const exports = (await Promise.all(args.urls.map((url) => import(url)))).flatMap(Object.values); const stores = exports.filter((value) => value && typeof value.getState === 'function'); return { game: stores.find((store) => typeof store.getState()?.relocatePlayer === 'function'), shell: stores.find((store) => typeof store.getState()?.saveActiveProgression === 'function'), combat: stores.find((store) => typeof store.getState()?.castAbility === 'function'), party: stores.find((store) => typeof store.getState()?.setCommand === 'function' && Array.isArray(store.getState()?.members)), }; }; }, { urls: moduleUrls }); try { await page.goto('http://127.0.0.1:4196', { waitUntil: 'domcontentloaded' }); await page.getByRole('button', { name: /Play offline/ }).click(); await page.getByRole('button', { name: /Create character/ }).click(); await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click(); const primaryClassName = fullFightsOnly ? 'Priest' : 'Knight'; const primaryClassId = fullFightsOnly ? 'rom-priest' : 'rom-knight'; const secondaryClassId = fullFightsOnly ? 'rom-knight' : 'rom-scout'; await page.locator('.class-option').filter({ hasText: primaryClassName }).click(); await page.getByRole('button', { name: /Customize & name/ }).click(); await page.getByLabel('Character name').fill(fullFightsOnly ? 'NativeHealer' : 'NativeCombat'); await page.getByRole('button', { name: /Create character/ }).click(); await page.locator('.character-select-screen').waitFor(); await page.waitForTimeout(2_000); if (!fullFightsOnly) await page.screenshot({ path: path.join(directory, 'roster.png') }); await page.evaluate(async ({ expectedPrimaryClassId, expectedSecondaryClassId }) => { const { shell } = await window.nativeCombatStores(); const state = shell.getState(); const character = state.characters.find((entry) => entry.id === state.selectedCharacterId); if (!character) throw new Error('New playtest character profile missing.'); if (character.classId !== expectedPrimaryClassId) throw new Error(`Expected ${expectedPrimaryClassId}, got ${character.classId}.`); shell.setState({ activeCharacter: character }); shell.getState().saveActiveProgression(30, 0, character.talentRanks); shell.setState({ activeCharacter: null }); if (!shell.getState().chooseRomSecondaryClass(expectedSecondaryClassId)) { throw new Error(`Canonical ${expectedPrimaryClassId}/${expectedSecondaryClassId} pairing failed.`); } window.nativeCombatFullFightRosterReady = true; }, { expectedPrimaryClassId: primaryClassId, expectedSecondaryClassId: secondaryClassId }); await page.waitForFunction(() => window.nativeCombatFullFightRosterReady === true); if (fullFightsOnly) { await page.waitForTimeout(1_500); await page.screenshot({ path: path.join(directory, 'roster-native-healer-level-30-body.png') }); } await page.evaluate(async () => { const { shell } = await window.nativeCombatStores(); shell.getState().openMainMenu(); }); for (const bossName of (fixturesOnly || fullFightsOnly ? [] : ['Anglo', 'Lady Anacondra'])) { await page.locator('.main-menu-screen').waitFor(); await page.getByRole('button', { name: /^GM Menu/ }).click(); await page.getByRole('button', { name: /Boss Testing/ }).click(); const picker = page.getByLabel(/Choose a boss/); await picker.waitFor({ state: 'visible' }); await page.waitForFunction(() => document.querySelector('.boss-testing-picker select')?.options.length > 1); const option = await picker.locator('option').evaluateAll((options, name) => options.find((entry) => entry.textContent.startsWith(`${name} ·`))?.value, bossName); assert(option, `${bossName} selectable`); await picker.selectOption(option); await page.getByRole('button', { name: 'Tank', exact: true }).click(); await page.getByRole('button', { name: 'Test selected boss', exact: true }).click(); await page.locator('.party-frames').waitFor(); await page.locator('.loading-screen').waitFor({ state: 'detached' }); await page.evaluate(async () => { const { game } = await window.nativeCombatStores(); game.getState().closeOverlay(); }); await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached' }); await page.waitForFunction(async () => { const { game, combat } = await window.nativeCombatStores(); return game.getState().assetStatus === 'ready' && Object.values(combat.getState().mobs).some((mob) => mob.homePosition); }); await page.evaluate(async () => { const { game, combat } = await window.nativeCombatStores(); const mob = Object.values(combat.getState().mobs).find((entry) => entry.boss && entry.homePosition); const [x, y, z] = mob.homePosition; game.getState().relocatePlayer({ footPosition: [x + 2.5, y, z], yaw: Math.PI / 2 }); combat.getState().setPlayerPosition(game.getState().playerPosition); combat.getState().selectMob(mob.id); combat.getState().damageMob(mob.id, 1, Date.now()); window.nativeCombatBossId = mob.id; }); await page.waitForTimeout(5_000); await page.screenshot({ path: path.join(directory, `${bossName.replaceAll(' ', '-')}-combat.png`) }); let mechanicObserved = false; for (let attempt = 0; attempt < 60 && !mechanicObserved; attempt += 1) { mechanicObserved = await page.evaluate(async () => { const { combat } = await window.nativeCombatStores(); const mob = combat.getState().mobs[window.nativeCombatBossId]; return mob.attacks.some((attack) => attack.id !== 'basic-attack' && (mob.abilityCastCounts[attack.id] ?? 0) > 0); }); if (!mechanicObserved) await page.waitForTimeout(1_000); } // Frame the fight from behind the room entrance after observing the cast. await page.evaluate(async () => { const { game, combat } = await window.nativeCombatStores(); const [x, y, z] = game.getState().activeSpawn.footPosition; game.getState().relocatePlayer({ footPosition: [x, y, z + 12], yaw: Math.PI }); combat.getState().setPlayerPosition(game.getState().playerPosition); }); await page.waitForTimeout(750); await page.screenshot({ path: path.join(directory, `${bossName.replaceAll(' ', '-')}-mechanic.png`) }); const sample = await page.evaluate(async () => { const { combat, party } = await window.nativeCombatStores(); const state = combat.getState(); const mob = state.mobs[window.nativeCombatBossId]; return { classId: state.classId, secondaryClassId: state.secondaryClassId, gearRole: state.gearRole, statRuleset: state.statRuleset, level: state.level, health: state.health, boss: mob.name, bossHealth: mob.health, bossMaxHealth: mob.maxHealth, nativeDamageBudgetScale: mob.nativeDamageBudgetScale ?? null, attackDamage: mob.attackDamage, damageMultiplier: mob.damageMultiplier, runewakerNpcId: mob.runewakerNpcId, attackRevision: mob.attackRevision, abilityCastCounts: mob.abilityCastCounts, attacks: mob.attacks.map((attack) => ({ id: attack.id, name: attack.name, spellId: attack.spellId, school: attack.school, attackKind: attack.attackKind })), party: party?.getState().members.map((member) => ({ classId: member.classId, statRuleset: member.statRuleset, health: member.health })), events: state.combatEvents.slice(-100), }; }); fs.writeFileSync(path.join(directory, `${bossName.replaceAll(' ', '-')}-sample.json`), `${JSON.stringify(sample, null, 2)}\n`); assert.equal(sample.statRuleset, 'rom'); assert.equal(sample.level, 30); assert.equal(sample.secondaryClassId, 'rom-scout'); assert.equal(sample.gearRole, 'tank'); assert.equal(sample.nativeDamageBudgetScale, 3, `${bossName} live level-30 scene uses the native point budget`); assert(Number.isFinite(sample.attackDamage), `${bossName} records its live scaled attackDamage`); assert(sample.attackRevision > 0, `${bossName} executes combat`); assert(sample.attacks.some((attack) => attack.id !== 'basic-attack' && sample.abilityCastCounts[attack.id] > 0), `${bossName} executes an authored mechanic`); samples.push(sample); await page.keyboard.press('Escape'); await page.getByRole('button', { name: /^(?:Return to main menu|Main menu)$/ }).click(); } if (fullFightsOnly) { for (const bossName of ['Anglo', 'Lady Anacondra']) { await page.locator('.main-menu-screen').waitFor(); samples.push(await runNativeHealerFullFight(page, bossName, directory)); if (bossName !== 'Lady Anacondra') { await page.keyboard.press('Escape'); await page.getByRole('button', { name: /^(?:Return to main menu|Main menu)$/ }).click(); } } } if (!fullFightsOnly) { if (fixturesOnly) { for (const filename of ['Anglo-sample.json', 'Lady-Anacondra-sample.json']) { const samplePath = path.join(directory, filename); if (!fs.existsSync(samplePath)) throw new Error(`Fixture-only mode requires the successful normal-scene snapshot ${filename}.`); const sample = JSON.parse(fs.readFileSync(samplePath, 'utf8')); assert.equal(sample.nativeDamageBudgetScale, 3, `${sample.boss} prior normal-scene snapshot has native budget 3`); assert(Number.isFinite(sample.attackDamage), `${sample.boss} prior normal-scene snapshot records attackDamage`); samples.push(sample); } } // Targeted source-Anglo browser probes run in additional real Boss Testing // scenes. These are explicitly cooldown fixtures: source cast time, range, // school, effect, damage and telegraph are copied unchanged; only the // source cooldown schedule is overridden to make each warning observable. await page.locator('.main-menu-screen').waitFor(); await page.getByRole('button', { name: /^GM Menu/ }).click(); await page.getByRole('button', { name: /Boss Testing/ }).click(); const fixturePicker = page.getByLabel(/Choose a boss/); await fixturePicker.waitFor({ state: 'visible' }); await page.waitForFunction(() => document.querySelector('.boss-testing-picker select')?.options.length > 1); const angloOption = await fixturePicker.locator('option').evaluateAll((options) => options.find((entry) => entry.textContent.startsWith('Anglo ·'))?.value); assert(angloOption, 'Anglo selectable for targeted source mechanic fixtures'); await fixturePicker.selectOption(angloOption); await page.getByRole('button', { name: 'Tank', exact: true }).click(); await page.getByRole('button', { name: 'Test selected boss', exact: true }).click(); await page.locator('.party-frames').waitFor(); await page.locator('.loading-screen').waitFor({ state: 'detached' }); await page.evaluate(async () => { const { game } = await window.nativeCombatStores(); game.getState().closeOverlay(); }); await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached' }); await page.waitForFunction(async () => { const { game, combat } = await window.nativeCombatStores(); return game.getState().assetStatus === 'ready' && Object.values(combat.getState().mobs).some((mob) => mob.runewakerNpcId === 101462 && mob.homePosition); }); const fixtureMobId = await page.evaluate(async () => { const { game, combat } = await window.nativeCombatStores(); const mob = Object.values(combat.getState().mobs).find((entry) => entry.runewakerNpcId === 101462 && entry.homePosition); if (!mob) throw new Error('Native Anglo NPC 101462 is missing from the targeted fixture scene.'); const [x, y, z] = mob.homePosition; const playerPosition = [x + 2.5, y, z]; window.nativeCombatFixtureOriginPlayerPosition = playerPosition; game.getState().relocatePlayer({ footPosition: playerPosition, yaw: Math.PI / 2 }); combat.getState().setPlayerPosition(game.getState().playerPosition); combat.getState().selectMob(mob.id); combat.getState().damageMob(mob.id, 1, Date.now(), { actorId: '__player__', role: 'tank' }); window.nativeCombatFixtureMobId = mob.id; return mob.id; }); await page.waitForFunction((mobId) => window.nativeCombatStores().then(({ combat }) => { const mob = combat.getState().mobs[mobId]; return Boolean(mob?.engaged && mob.runewakerNpcId === 101462); }), fixtureMobId); const runAngloFixture = async (attackName, { moveDuringWarning = false, pausePartyActions = false } = {}) => { const setup = await page.evaluate(async ({ attackName, pausePartyActions }) => { const { game, combat, party } = await window.nativeCombatStores(); const before = combat.getState(); const mob = before.mobs[window.nativeCombatFixtureMobId]; const fixtureOrigin = window.nativeCombatFixtureOriginPlayerPosition; if (fixtureOrigin) { game.getState().relocatePlayer({ footPosition: fixtureOrigin, yaw: Math.PI / 2 }); combat.getState().setPlayerPosition(game.getState().playerPosition); } // Cache the complete imported list before the one-attack fixture is // installed. Each probe then selects from the untouched source packet. if (mob && window.nativeCombatFixtureOriginalAttacks === undefined) { window.nativeCombatFixtureOriginalAttacks = mob.attacks; } const attack = window.nativeCombatFixtureOriginalAttacks?.find((candidate) => candidate.name === attackName); if (!mob || mob.runewakerNpcId !== 101462 || !attack) throw new Error(`Source Anglo ${attackName} attack is missing.`); const targetTime = Date.now(); if (pausePartyActions && window.nativeCombatFixtureSavedParty === undefined) { window.nativeCombatFixtureSavedParty = party.getState().members; party.setState({ members: window.nativeCombatFixtureSavedParty.map((member) => ({ ...member, nextActionAt: Number.MAX_SAFE_INTEGER, })) }); } const attacks = [{ ...attack, initialCooldownMs: [0, 0], repeatCooldownMs: [60_000, 60_000] }]; const abilityReadyAt = { [attack.id]: targetTime - 1 }; const threatByActor = { ...mob.threatByActor, __player__: Math.max(mob.threatByActor.__player__ ?? 0, 1_000_000), }; combat.setState({ mobs: { ...before.mobs, [mob.id]: { ...mob, attacks, abilityReadyAt, activeCast: null, nextAttackAt: targetTime - 1, nextAttackIndex: 0, targetActorId: '__player__', threatByActor, engaged: true, } } }); const playerPosition = game.getState().playerPosition; combat.getState().advanceMobCombat(targetTime, playerPosition); const started = combat.getState().mobs[mob.id]; if (started?.activeCast?.attackId !== attack.id) { throw new Error(`Cooldown fixture did not start the actual Anglo ${attackName} source cast: ${JSON.stringify({ runewakerNpcId: started?.runewakerNpcId, engaged: started?.engaged, targetActorId: started?.targetActorId, targetThreat: started?.threatByActor.__player__, playerPosition: game.getState().playerPosition, homePosition: started?.homePosition, nextAttackAt: started?.nextAttackAt, nextAttackIndex: started?.nextAttackIndex, abilityReadyAt: started?.abilityReadyAt, schoolLockedUntil: started?.schoolLockedUntil, activeCast: started?.activeCast, attacks: started?.attacks.map((entry) => ({ id: entry.id, castTimeMs: entry.castTimeMs, range: entry.range, target: entry.target, initialCooldownMs: entry.initialCooldownMs, })), })}`); } return { mobId: mob.id, attackId: attack.id, beforeCastCount: mob.abilityCastCounts[attack.id] ?? 0, sourceAttack: { spellId: attack.spellId, castTimeMs: attack.castTimeMs ?? 0, range: attack.range, school: attack.school, target: attack.target, effect: attack.effect, damageMultiplier: attack.damageMultiplier, telegraph: attack.telegraph, }, fixtureOverrides: { initialCooldownMs: [0, 0], repeatCooldownMs: [60_000, 60_000], attackList: [attack.id], playerTankThreatSeeded: true, partyActionsPaused: pausePartyActions, sourceCastTimeRangeEffectAndTelegraphChanged: false, }, activeCast: started.activeCast, partyComposition: party.getState().members.map((member) => ({ id: member.id, classId: member.classId, secondaryClassId: member.secondaryClassId, role: member.role, specialization: member.specialization, cooldownsBefore: member.cooldowns, })), }; }, { attackName, pausePartyActions }); const slug = attackName.toLowerCase().replace(/[^a-z0-9]+/g, '-'); await page.waitForTimeout(150); await page.screenshot({ path: path.join(directory, `anglo-${slug}-warning-fixture.png`) }); let movement = null; if (moveDuringWarning) { const beforeMovement = await page.evaluate(async ({ mobId, fallbackRadius }) => { const { game, combat } = await window.nativeCombatStores(); const mob = combat.getState().mobs[mobId]; if (!mob?.activeCast) throw new Error('Anglo telegraph expired before movement probe.'); const telegraphCenter = mob.activeCast.sourcePosition; const from = game.getState().playerPosition; const facingYaw = Math.atan2(from[0] - telegraphCenter[0], from[2] - telegraphCenter[2]); game.getState().relocatePlayer({ footPosition: from, yaw: facingYaw }); combat.getState().setPlayerPosition(game.getState().playerPosition); return { from, facingYaw, sourceCastId: mob.activeCast.attackId, distanceFromTelegraphCenter: Math.hypot(game.getState().playerPosition[0] - telegraphCenter[0], game.getState().playerPosition[1] - telegraphCenter[1], game.getState().playerPosition[2] - telegraphCenter[2]), radius: mob.activeCast.telegraph?.radius ?? fallbackRadius, sourceCastPreservedDuringMove: combat.getState().mobs[mobId]?.activeCast?.attackId === mob.activeCast.attackId, }; }, { mobId: setup.mobId, fallbackRadius: setup.sourceAttack.telegraph?.radius ?? 0 }); await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur()); await page.keyboard.down('w'); await page.waitForTimeout(850); await page.keyboard.up('w'); movement = await page.evaluate(async ({ mobId, beforeMovement, input }) => { const { game, combat } = await window.nativeCombatStores(); const mob = combat.getState().mobs[mobId]; const to = game.getState().playerPosition; return { input, facingYaw: beforeMovement.facingYaw, from: beforeMovement.from, to, distanceBeforeFromTelegraphCenter: beforeMovement.distanceFromTelegraphCenter, distanceFromTelegraphCenter: Math.hypot(to[0] - mob.activeCast.sourcePosition[0], to[1] - mob.activeCast.sourcePosition[1], to[2] - mob.activeCast.sourcePosition[2]), telegraphRadius: beforeMovement.radius, sourceCastPreservedDuringMove: mob?.activeCast?.attackId === beforeMovement.sourceCastId, }; }, { mobId: setup.mobId, beforeMovement, input: 'W' }); movement.distanceTravelled = positionDistance(movement.from, movement.to); assert(movement.sourceCastPreservedDuringMove, `${attackName} source cast remains active during movement fixture`); assert(movement.distanceTravelled > 0.5, `${attackName} fixture applies real W-key movement: ${JSON.stringify(movement)}`); assert(movement.distanceBeforeFromTelegraphCenter < movement.telegraphRadius, `${attackName} fixture starts the player inside the live source telegraph: ${JSON.stringify(movement)}`); assert(movement.distanceFromTelegraphCenter > movement.telegraphRadius, `${attackName} W-key movement clears the live source telegraph radius: ${JSON.stringify(movement)}`); await page.waitForTimeout(150); await page.screenshot({ path: path.join(directory, `anglo-${slug}-movement-fixture.png`) }); } // Let the exact source cast finish before sampling. The source cast time // above is untouched; using a bounded duration also keeps the fixture // independent of another UI frame changing the selected target. await page.waitForTimeout(Math.max(0, setup.sourceAttack.castTimeMs) + 500); const result = await page.evaluate(async ({ mobId, attackId, beforeCastCount, moveDuringWarning }) => { const { game, combat, party } = await window.nativeCombatStores(); const state = combat.getState(); const mob = state.mobs[mobId]; return { sourceAttackId: attackId, castCountDelta: (mob?.abilityCastCounts[attackId] ?? 0) - beforeCastCount, activeCastAfterExpectedRelease: mob?.activeCast?.name ?? null, lastAttackId: mob?.lastAttackId ?? null, attackRevision: mob?.attackRevision ?? null, playerPosition: game.getState().playerPosition, playerHealth: state.health, playerControlMechanic: state.controlMechanic, playerControlledUntil: state.controlledUntil, matchingControlAuras: state.auras.filter((aura) => aura.targetId === '__player__' && aura.definition.id.startsWith('enemy-control:') && aura.sourceId === mobId).map((aura) => ({ id: aura.definition.id, name: aura.definition.name, expiresAt: aura.expiresAt })), party: party.getState().members.map((member) => ({ id: member.id, classId: member.classId, role: member.role, status: member.status, controlMechanic: member.controlMechanic, controlledUntil: member.controlledUntil, activeCast: member.activeCast?.abilityId ?? null, cooldowns: member.cooldowns })), partyControlAuras: state.auras.filter((aura) => aura.sourceId === mobId && aura.definition.id.startsWith('enemy-control:')).map((aura) => ({ targetId: aura.targetId, name: aura.definition.name, expiresAt: aura.expiresAt })), relevantCombatEvents: state.combatEvents.filter((event) => event.sourceActorId === mobId || event.abilityId === attackId).slice(-30), playerMovedDuringWarning: moveDuringWarning, }; }, { mobId: setup.mobId, attackId: setup.attackId, beforeCastCount: setup.beforeCastCount, moveDuringWarning }); assert.equal(result.castCountDelta, 1, `${attackName} source ability releases exactly once in fixture`); if (attackName === 'Trample' && moveDuringWarning) { const hitsOnMovedPlayer = result.relevantCombatEvents.filter((event) => event.kind === 'damage' && event.sourceActorId === setup.mobId && event.targetActorId === '__player__' && event.abilityId === setup.attackId && event.effectiveAmount > 0); result.trampleDamageEventsOnOtherActors = result.relevantCombatEvents.filter((event) => event.kind === 'damage' && event.sourceActorId === setup.mobId && event.targetActorId !== '__player__' && event.abilityId === setup.attackId && event.effectiveAmount > 0) .map((event) => ({ targetActorId: event.targetActorId, effectiveAmount: event.effectiveAmount, outcome: event.outcome })); assert.deepEqual(hitsOnMovedPlayer, [], 'Trample movement fixture moves the player out of the actual source hit area'); result.movedPlayerTrampleDamageEvents = hitsOnMovedPlayer.length; } if (attackName === 'Gulp') { assert(result.playerControlMechanic === 'stun' || result.matchingControlAuras.length > 0 || result.party.some((member) => member.controlMechanic === 'stun'), 'Gulp applied its source stun to an in-area actor'); result.gulpReaction = 'observed-applied'; const initialControlTargets = [ ...result.matchingControlAuras.map((aura) => ({ targetId: '__player__', expiresAt: aura.expiresAt })), ...result.partyControlAuras.map((aura) => ({ targetId: aura.targetId, expiresAt: aura.expiresAt })), ]; if (result.playerControlMechanic === 'stun' && !initialControlTargets.some((target) => target.targetId === '__player__')) { initialControlTargets.push({ targetId: '__player__', expiresAt: result.playerControlledUntil }); } if (!initialControlTargets.length) throw new Error('Gulp cast count advanced without a target control snapshot.'); result.gulpControlTargetsAtApplication = initialControlTargets; await page.screenshot({ path: path.join(directory, 'anglo-gulp-applied-fixture.png') }); for (let attempt = 0; attempt < 32; attempt += 1) { const after = await page.evaluate(async (mobId) => { const { combat, party } = await window.nativeCombatStores(); const state = combat.getState(); const memberIds = new Set(party.getState().members.map((member) => member.id)); return { now: Date.now(), playerControlMechanic: state.controlMechanic, playerControlledUntil: state.controlledUntil, playerAura: state.auras.some((aura) => aura.targetId === '__player__' && aura.sourceId === mobId && aura.definition.id.startsWith('enemy-control:')), memberControls: party.getState().members.map((member) => ({ id: member.id, classId: member.classId, controlMechanic: member.controlMechanic, controlledUntil: member.controlledUntil })), controlAuras: state.auras.filter((aura) => aura.sourceId === mobId && aura.definition.id.startsWith('enemy-control:')).map((aura) => ({ targetId: aura.targetId, isPartyMember: memberIds.has(aura.targetId), name: aura.definition.name, expiresAt: aura.expiresAt, })), partyMembers: party.getState().members.map((member) => ({ id: member.id, classId: member.classId, role: member.role, status: member.status, controlMechanic: member.controlMechanic, controlledUntil: member.controlledUntil, cooldowns: member.cooldowns, activeCast: member.activeCast?.abilityId ?? null })), }; }, setup.mobId); const unresolvedControlTargets = initialControlTargets.filter((target) => { const activeAura = after.controlAuras.some((aura) => aura.targetId === target.targetId && aura.expiresAt > after.now); const activeControl = target.targetId === '__player__' ? after.playerControlMechanic === 'stun' && after.playerControlledUntil > after.now : after.partyMembers.some((member) => member.id === target.targetId && member.controlMechanic === 'stun' && member.controlledUntil > after.now); return activeAura || activeControl; }); if (!unresolvedControlTargets.length) { const clearedBeforeExpiry = initialControlTargets.every((target) => target.expiresAt > after.now); result.gulpReaction = clearedBeforeExpiry ? 'party-control-cleared-before-source-expiry' : 'party-control-cleared-at-or-after-source-expiry'; result.gulpControlClearedBeforeSourceExpiry = clearedBeforeExpiry; result.partyReactionAfterGulp = after; break; } await page.waitForTimeout(100); } const gulpState = await page.evaluate(async (mobId) => { const { combat, party } = await window.nativeCombatStores(); const state = combat.getState(); return { playerControlMechanic: state.controlMechanic, playerControlledUntil: state.controlledUntil, matchingControlAuras: state.auras.filter((aura) => aura.targetId === '__player__' && aura.sourceId === mobId && aura.definition.id.startsWith('enemy-control:')) .map((aura) => ({ name: aura.definition.name, expiresAt: aura.expiresAt })), party: party.getState().members.map((member) => ({ id: member.id, classId: member.classId, role: member.role, status: member.status, controlMechanic: member.controlMechanic, controlledUntil: member.controlledUntil, activeCast: member.activeCast?.abilityId ?? null, cooldowns: member.cooldowns })), remainingControlAuras: state.auras.filter((aura) => aura.sourceId === mobId && aura.definition.id.startsWith('enemy-control:')).map((aura) => ({ targetId: aura.targetId, name: aura.definition.name, expiresAt: aura.expiresAt, })), }; }, setup.mobId); result.gulpAfterReactionWindow = gulpState; const partyAfterReaction = result.partyReactionAfterGulp?.partyMembers ?? gulpState.party; result.partyCooldownChangesAfterGulp = partyAfterReaction.map((member) => { const before = setup.partyComposition.find((candidate) => candidate.id === member.id)?.cooldownsBefore ?? {}; const changedCooldowns = Object.entries(member.cooldowns ?? {}) .filter(([abilityId, readyAt]) => before[abilityId] !== readyAt) .map(([abilityId, readyAt]) => ({ abilityId, readyAt })); return { memberId: member.id, classId: member.classId, role: member.role, changedCooldowns }; }); result.priestCooldownChangesAfterGulp = result.partyCooldownChangesAfterGulp .filter((member) => String(member.classId).includes('priest') && member.changedCooldowns.length > 0); const priestDispelCooldownChanges = result.priestCooldownChangesAfterGulp.flatMap((member) => member.changedCooldowns.filter(({ abilityId }) => /dispel|cleanse|cure/i.test(abilityId)) .map(({ abilityId }) => ({ memberId: member.memberId, abilityId }))); result.gulpClearingAttribution = priestDispelCooldownChanges.length > 0 && result.gulpControlClearedBeforeSourceExpiry ? { kind: 'npc-priest-dispel-ability-cooldown-observed', abilities: priestDispelCooldownChanges } : result.gulpControlClearedBeforeSourceExpiry ? { kind: 'party-control-cleared-before-expiry;-cleanse-attribution-unverified' } : result.gulpReaction === 'party-control-cleared-at-or-after-source-expiry' ? { kind: 'control-cleared-at-or-after-expiry; no NPC cleanse evidence' } : { kind: 'control-remained-active-through-observation-window' }; await page.screenshot({ path: path.join(directory, 'anglo-gulp-party-reaction-fixture.png') }); } fixtureSamples.push({ fixture: 'source-Anglo cooldown fixture; source cast/range/effect/telegraph retained', attackName, setup, movement, result }); await page.evaluate(async (mobId) => { const { combat, party } = await window.nativeCombatStores(); const state = combat.getState(); const mob = state.mobs[mobId]; if (mob) combat.setState({ mobs: { ...state.mobs, [mobId]: { ...mob, attacks: window.nativeCombatFixtureOriginalAttacks ?? mob.attacks, engaged: false, activeCast: null, } } }); if (window.nativeCombatFixtureSavedParty !== undefined) { party.setState({ members: window.nativeCombatFixtureSavedParty }); delete window.nativeCombatFixtureSavedParty; } }, setup.mobId); }; await runAngloFixture('Trample', { moveDuringWarning: true, pausePartyActions: true }); await runAngloFixture('Bash'); await runAngloFixture('Gulp'); } assert.deepEqual(errors, []); fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ buildChunks: moduleUrls, errors, samples, fixtureSamples, normalScenesReran: !fixturesOnly && !fullFightsOnly, normalSceneSnapshotsLoaded: fixturesOnly, fullHealerBossFightsReran: fullFightsOnly }, null, 2)}\n`); console.log(`Native combat scene QA passed: ${directory}`); } catch (error) { await page.screenshot({ path: path.join(directory, 'failure.png') }); console.error((await page.locator('body').innerText()).slice(-2_000)); throw error; } finally { await browser.close(); await new Promise((resolve) => runtime.server.close(resolve)); const target = fs.realpathSync(dataDir); if (path.dirname(target) !== fs.realpathSync(os.tmpdir()) || !path.basename(target).startsWith('healer-man-native-combat-')) throw new Error('Unsafe QA cleanup path.'); fs.rmSync(target, { recursive: true, force: true }); } })().catch((error) => { console.error(error); process.exitCode = 1; });