Files
healer-man/scripts/playtest-native-combat.cjs
T
2026-10-04 22:51:21 -04:00

1063 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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; });