1063 lines
65 KiB
JavaScript
1063 lines
65 KiB
JavaScript
// 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; });
|