794 lines
46 KiB
JavaScript
794 lines
46 KiB
JavaScript
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
|
const assert = require('node:assert/strict');
|
|
const crypto = require('node:crypto');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
// UI-driven, final-build Boss Testing proof for Vow of Silence against the
|
|
// source-scheduled Lady Anacondra Lightning Bolt. Store access is read-only
|
|
// during combat; only the isolated profile's level and secondary class are set.
|
|
const artifactRoot = path.resolve('playtest-artifacts/vow-live-final');
|
|
const stamp = new Date().toISOString().replace(/[-:.]/g, '').replace('T', '-').replace('Z', '');
|
|
const outputDirectory = path.join(artifactRoot, `final-build-${stamp}`);
|
|
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-vow-live-final-'));
|
|
const liveFightBudgetMs = 80_000;
|
|
const vowSilenceWindowMs = 8_400;
|
|
const actionSlots = [
|
|
['primary:face-bottom', '1'], ['primary:face-left', '2'], ['primary:face-top', '3'],
|
|
['primary:face-right', '4'], ['primary:r1', '5'], ['primary:r2', '6'],
|
|
['primary:dpad-left', '7'], ['primary:dpad-right', '8'],
|
|
];
|
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
|
|
|
function flatEffects(effects = []) {
|
|
return effects.flatMap((effect) => effect.kind === 'composite' ? flatEffects(effect.effects) : [effect]);
|
|
}
|
|
|
|
function actionPlanFromSnapshot(snapshot, vowId) {
|
|
const abilities = new Map(snapshot.abilities.map((ability) => [ability.id, ability]));
|
|
const bound = actionSlots.flatMap(([bindingId, key]) => {
|
|
const id = snapshot.actionBindings[bindingId];
|
|
const ability = id ? abilities.get(id) : null;
|
|
return ability && ability.id !== vowId && !ability.passive
|
|
&& (ability.unlockLevel ?? 1) <= snapshot.player.level
|
|
? [{ key, ability, effects: flatEffects(ability.effects) }] : [];
|
|
});
|
|
const healing = bound.find(({ ability, effects }) => ability.target === 'friendly'
|
|
&& effects.some((effect) => effect.kind === 'heal')) ?? null;
|
|
const damage = bound.filter(({ ability, effects }) => ability.target === 'hostile'
|
|
&& effects.some((effect) => effect.kind === 'damage'))
|
|
.sort((left, right) => Number(right.ability.range?.max ?? 0) - Number(left.ability.range?.max ?? 0))[0] ?? null;
|
|
return {
|
|
healing: healing ? { key: healing.key, id: healing.ability.id, name: healing.ability.name,
|
|
range: healing.ability.range ?? null } : null,
|
|
damage: damage ? { key: damage.key, id: damage.ability.id, name: damage.ability.name,
|
|
range: damage.ability.range ?? null } : null,
|
|
bound: bound.map(({ key, ability }) => ({ key, id: ability.id, name: ability.name,
|
|
target: ability.target, range: ability.range ?? null })),
|
|
};
|
|
}
|
|
|
|
async function readLiveState(page, includeActionSetup = false) {
|
|
return page.evaluate((includeSetup) => window.vowFinalStores().then(({ game, combat, party,
|
|
getMobPosition, getMobTransform, getPartyPosition }) => {
|
|
const gameState = game.getState();
|
|
const combatState = combat.getState();
|
|
return {
|
|
now: Date.now(),
|
|
game: {
|
|
activeDungeonId: gameState.activeDungeonId,
|
|
gameMode: gameState.gameMode,
|
|
gmTestMode: gameState.gmTestMode,
|
|
bossTest: gameState.bossTest,
|
|
assetStatus: gameState.assetStatus,
|
|
activeSpawn: gameState.activeSpawn,
|
|
playerPosition: gameState.playerPosition,
|
|
cameraYaw: gameState.cameraYaw ?? null,
|
|
overlay: gameState.overlay,
|
|
dungeonIntroActive: gameState.dungeonIntroActive,
|
|
},
|
|
player: {
|
|
classId: combatState.classId,
|
|
secondaryClassId: combatState.secondaryClassId,
|
|
statRuleset: combatState.statRuleset,
|
|
level: combatState.level,
|
|
experience: combatState.xp,
|
|
health: combatState.health,
|
|
maxHealth: combatState.maxHealth,
|
|
resource: combatState.resource,
|
|
maxResource: combatState.maxResource,
|
|
resourceType: combatState.resourceType,
|
|
resourceName: combatState.resourceName,
|
|
position: gameState.playerPosition,
|
|
cameraYaw: gameState.cameraYaw ?? null,
|
|
selectedTargetId: combatState.selectedTargetId,
|
|
activeCast: combatState.activeCast?.abilityId ?? null,
|
|
globalCooldownEndsAt: combatState.globalCooldownEndsAt,
|
|
cooldowns: combatState.cooldowns,
|
|
},
|
|
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,
|
|
status: member.status,
|
|
activeCast: member.activeCast?.abilityId ?? null,
|
|
position: getPartyPosition?.(member.id) ?? null,
|
|
})),
|
|
mobs: Object.values(combatState.mobs).filter((mob) => !mob.despawned).map((mob) => ({
|
|
id: mob.id,
|
|
name: mob.name,
|
|
serverEntry: mob.serverEntry,
|
|
runewakerNpcId: mob.runewakerNpcId ?? null,
|
|
creatureFamily: mob.creatureFamily,
|
|
level: mob.level,
|
|
health: mob.health,
|
|
maxHealth: mob.maxHealth,
|
|
dead: mob.dead,
|
|
diedAt: mob.diedAt ?? null,
|
|
engaged: mob.engaged,
|
|
hasLoot: mob.hasLoot,
|
|
pendingLootCount: mob.pendingLoot?.length ?? 0,
|
|
position: getMobPosition?.(mob.id) ?? mob.position ?? mob.homePosition,
|
|
transform: getMobTransform?.(mob.id) ?? null,
|
|
attackRevision: mob.attackRevision,
|
|
abilityCastCounts: mob.abilityCastCounts,
|
|
abilityReadyAt: mob.abilityReadyAt,
|
|
schoolLockedUntil: mob.schoolLockedUntil,
|
|
mechanicImmunities: mob.mechanicImmunities,
|
|
schoolImmunities: mob.schoolImmunities,
|
|
auras: mob.auras,
|
|
activeCast: mob.activeCast ? {
|
|
name: mob.activeCast.name,
|
|
attackId: mob.activeCast.attackId,
|
|
spellId: mob.activeCast.spellId ?? null,
|
|
school: mob.activeCast.school,
|
|
startedAt: mob.activeCast.startedAt,
|
|
completesAt: mob.activeCast.completesAt,
|
|
interruptible: mob.activeCast.interruptible,
|
|
} : null,
|
|
statuses: mob.statuses,
|
|
attacks: mob.attacks.map((attack) => ({ id: attack.id, name: attack.name,
|
|
spellId: attack.spellId ?? null, castTimeMs: attack.castTimeMs ?? 0,
|
|
cooldownMs: attack.cooldownMs ?? 0, school: attack.school,
|
|
interruptible: attack.interruptible !== false })),
|
|
boss: mob.boss,
|
|
})),
|
|
...(includeSetup ? { abilities: combatState.abilities, actionBindings: combatState.actionBindings } : {}),
|
|
feedback: combatState.feedback ? { kind: combatState.feedback.kind,
|
|
message: combatState.feedback.message, abilityId: combatState.feedback.abilityId ?? null,
|
|
targetId: combatState.feedback.targetId ?? null } : null,
|
|
events: combatState.combatEvents.slice(-200),
|
|
presentationEvents: window.vowFinalPresentationEvents.slice(-500),
|
|
presentationSubscriptionFound: window.vowFinalPresentationSubscriptionFound ?? false,
|
|
presentationSubscribeExportName: window.vowFinalPresentationSubscribeExportName ?? null,
|
|
presentationExportNames: window.vowFinalPresentationExportNames ?? [],
|
|
presentationSubscriptionCandidates: window.vowFinalPresentationSubscriptionCandidates ?? [],
|
|
};
|
|
}), includeActionSetup);
|
|
}
|
|
|
|
async function assignVowThroughAbilityControls(page, outputDirectory) {
|
|
const before = await readLiveState(page, true);
|
|
const vow = before.abilities.find((ability) => ability.dbcSpellId === 490275);
|
|
if (!vow) throw new Error('The UI-created Priest/Knight did not learn DBC spell 490275 at the prepared level.');
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForTimeout(300);
|
|
await page.getByRole('button', { name: 'Ability controls' }).click();
|
|
await page.locator('.ability-bindings__diagram').waitFor({ timeout: 15_000 });
|
|
await page.locator('.ability-bindings__layers button').nth(1).click();
|
|
await page.locator('button[aria-label*="Shift+5:"]').click();
|
|
await page.getByRole('button', { name: new RegExp(`^${vow.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:,|$)`) }).click();
|
|
await page.screenshot({ path: path.join(outputDirectory, 'vow-binding-ui.png') });
|
|
const after = await readLiveState(page, true);
|
|
const binding = Object.entries(after.actionBindings).find(([, id]) => id === vow.id)?.[0] ?? null;
|
|
await page.getByRole('button', { name: 'Close button assignments' }).click();
|
|
await page.getByRole('button', { name: 'Resume game' }).click();
|
|
assert.equal(binding, 'secondary:r1', 'Vow is bound through the UI to Shift+Digit5.');
|
|
return { id: vow.id, name: vow.name, dbcSpellId: vow.dbcSpellId,
|
|
unlockLevel: vow.unlockLevel, playerLevel: after.player.level, target: vow.target,
|
|
range: vow.range ?? null, radius: vow.radius ?? null, targeting: vow.targeting ?? null,
|
|
castMode: vow.castMode, castTimeMs: vow.castTimeMs, cooldownMs: vow.cooldownMs,
|
|
gcdMs: vow.gcdMs, cost: vow.cost, effects: vow.effects,
|
|
binding, input: 'Shift+Digit5' };
|
|
}
|
|
|
|
async function selectBossWithTab(page, bossId, attempts = 8) {
|
|
let snapshot = await readLiveState(page);
|
|
for (let attempt = 0; attempt < attempts && snapshot.player.selectedTargetId !== bossId; attempt += 1) {
|
|
await page.keyboard.press('Tab');
|
|
await page.waitForTimeout(70);
|
|
snapshot = await readLiveState(page);
|
|
}
|
|
return { selected: snapshot.player.selectedTargetId === bossId, snapshot };
|
|
}
|
|
|
|
async function selectPartyMember(page, member) {
|
|
const label = new RegExp(`^Target ${member.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')},`);
|
|
await page.getByRole('button', { name: label }).click();
|
|
}
|
|
|
|
async function selectPlayerFrame(page, playerName) {
|
|
const label = new RegExp(`^Target ${playerName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}, you$`);
|
|
await page.getByRole('button', { name: label }).click();
|
|
}
|
|
|
|
async function performCast(page, action, kind, intendedTargetId, reason) {
|
|
const before = await readLiveState(page);
|
|
await page.keyboard.press(action.key);
|
|
const startedAt = Date.now();
|
|
let after = await readLiveState(page);
|
|
const eventsFor = (state) => state.events.filter((event) => event.occurredAt >= before.now - 1
|
|
&& event.abilityId === action.id && event.sourceActorId === '__player__'
|
|
&& (kind === 'damage' ? event.kind === 'damage' : event.kind === 'healing'));
|
|
while (Date.now() - startedAt < 2_600) {
|
|
if (eventsFor(after).some((event) => event.effectiveAmount > 0)
|
|
|| (!after.player.activeCast && Date.now() - startedAt >= 650)) break;
|
|
await page.waitForTimeout(120);
|
|
after = await readLiveState(page);
|
|
}
|
|
const memberBefore = before.party.find((member) => member.id === intendedTargetId);
|
|
const memberAfter = after.party.find((member) => member.id === intendedTargetId);
|
|
return {
|
|
at: before.now,
|
|
key: action.key,
|
|
abilityId: action.id,
|
|
abilityName: action.name,
|
|
kind,
|
|
reason,
|
|
intendedTargetId,
|
|
selectedTargetIdBefore: before.player.selectedTargetId,
|
|
targetHealthBefore: kind === 'healing'
|
|
? memberBefore?.health ?? (intendedTargetId === '__player__' ? before.player.health : null)
|
|
: before.mobs.find((mob) => mob.id === intendedTargetId)?.health ?? null,
|
|
targetHealthAfter: kind === 'healing'
|
|
? memberAfter?.health ?? (intendedTargetId === '__player__' ? after.player.health : null)
|
|
: after.mobs.find((mob) => mob.id === intendedTargetId)?.health ?? null,
|
|
resolvedEvents: eventsFor(after),
|
|
actionResolved: eventsFor(after).some((event) => event.effectiveAmount > 0),
|
|
playerHealthBefore: before.player.health,
|
|
playerHealthAfter: after.player.health,
|
|
playerResourceBefore: before.player.resource,
|
|
playerResourceAfter: after.player.resource,
|
|
feedback: after.feedback,
|
|
atAfter: after.now,
|
|
};
|
|
}
|
|
|
|
function playerVitals(snapshot) {
|
|
return { health: snapshot.player.health, maxHealth: snapshot.player.maxHealth,
|
|
resource: snapshot.player.resource, maxResource: snapshot.player.maxResource,
|
|
resourceType: snapshot.player.resourceType, resourceName: snapshot.player.resourceName };
|
|
}
|
|
|
|
function partyVitals(snapshot) {
|
|
return snapshot.party.map((member) => ({ id: member.id, name: member.name,
|
|
classId: member.classId, secondaryClassId: member.secondaryClassId, role: member.role,
|
|
statRuleset: member.statRuleset, status: member.status,
|
|
health: member.health, maxHealth: member.maxHealth, resource: member.resource,
|
|
maxResource: member.maxResource, resourceType: member.resourceType }));
|
|
}
|
|
|
|
async function runFight(page, result, vowSetup, initial) {
|
|
const bossId = initial.game.bossTest.runtimeIds[0];
|
|
let current = initial;
|
|
const target = await selectBossWithTab(page, bossId);
|
|
if (!target.selected) throw new Error('Boss Testing UI targeting did not select the live Lady Anacondra.');
|
|
current = target.snapshot;
|
|
await page.keyboard.press('F1');
|
|
result.fight.startedAt = Date.now();
|
|
result.fight.partyAttackInput = { at: result.fight.startedAt, key: 'F1', command: 'Attack', targetId: bossId };
|
|
const wallDeadline = result.fight.startedAt + liveFightBudgetMs;
|
|
let vowAttempts = 0;
|
|
let nextDamageAt = 0;
|
|
let nextHealAt = 0;
|
|
let observedCastCountAtWindowStart = null;
|
|
let lastSampleAt = 0;
|
|
let lastPresentationCursor = current.presentationEvents.at(-1)?.id ?? 0;
|
|
let previousCastIdentity = null;
|
|
|
|
while (Date.now() < wallDeadline) {
|
|
current = await readLiveState(page);
|
|
const boss = current.mobs.find((mob) => mob.id === bossId);
|
|
if (!boss || boss.dead || boss.health <= 0) {
|
|
result.fight.outcome = boss?.dead || boss?.health <= 0 ? 'boss-defeated' : 'boss-despawned';
|
|
result.fight.bossAtEnd = boss ?? null;
|
|
break;
|
|
}
|
|
if (current.player.health <= 0) { result.fight.outcome = 'player-dead'; break; }
|
|
if (lastSampleAt === 0 || current.now - lastSampleAt >= 350) {
|
|
result.fight.samples.push({ at: current.now, bossHealth: boss.health, bossCast: boss.activeCast,
|
|
lightningBoltCount: boss.abilityCastCounts?.['spell-9532'] ?? null,
|
|
natureLockedUntil: boss.schoolLockedUntil?.nature ?? 0,
|
|
player: playerVitals(current), party: partyVitals(current) });
|
|
lastSampleAt = current.now;
|
|
}
|
|
for (const event of current.presentationEvents) {
|
|
if (event.id <= lastPresentationCursor) continue;
|
|
result.fight.presentationEvents.push(event);
|
|
lastPresentationCursor = Math.max(lastPresentationCursor, event.id);
|
|
}
|
|
|
|
if (current.player.selectedTargetId !== bossId) {
|
|
const selected = await selectBossWithTab(page, bossId, 4);
|
|
if (selected.selected) current = selected.snapshot;
|
|
}
|
|
|
|
const liveBoss = current.mobs.find((mob) => mob.id === bossId);
|
|
const liveCast = liveBoss?.activeCast;
|
|
const castIdentity = liveCast ? `${liveCast.attackId}:${liveCast.startedAt}` : null;
|
|
if (castIdentity !== previousCastIdentity) {
|
|
result.fight.castTimeline.push({ at: current.now, activeCast: liveCast });
|
|
previousCastIdentity = castIdentity;
|
|
}
|
|
const sourceLightning = liveBoss?.attacks.find((attack) => attack.spellId === 9532
|
|
|| attack.id === 'spell-9532') ?? null;
|
|
if (!sourceLightning || sourceLightning.castTimeMs <= 0) {
|
|
throw new Error('The live Lady Anacondra has no source-scheduled interruptible Lightning Bolt (9532) definition.');
|
|
}
|
|
if (!result.fight.sourceLightningDefinition) result.fight.sourceLightningDefinition = sourceLightning;
|
|
|
|
if (!result.fight.vowSuccess && vowAttempts < 4 && liveCast
|
|
&& liveCast.attackId === sourceLightning.id && liveCast.spellId === 9532
|
|
&& liveCast.interruptible && liveCast.completesAt - current.now > 450
|
|
&& current.player.selectedTargetId === bossId
|
|
&& current.player.globalCooldownEndsAt <= current.now
|
|
&& !current.player.activeCast) {
|
|
vowAttempts += 1;
|
|
const beforeCast = liveCast;
|
|
const presentationBefore = current.presentationEvents.at(-1)?.id ?? 0;
|
|
const eventsBefore = current.events.map((event) => event.id);
|
|
const attempt = { attempt: vowAttempts, observedAt: current.now,
|
|
castStartedAt: beforeCast.startedAt, scheduledCompletionAt: beforeCast.completesAt,
|
|
remainingMs: beforeCast.completesAt - current.now, activeCastBefore: beforeCast,
|
|
sourceAttack: sourceLightning, input: 'Shift+Digit5', selectedTargetId: current.player.selectedTargetId,
|
|
lightningBoltCountBefore: liveBoss.abilityCastCounts?.[sourceLightning.id] ?? null,
|
|
natureLockedUntilBefore: liveBoss.schoolLockedUntil?.nature ?? 0,
|
|
playerOriginBefore: current.player.position ?? null,
|
|
playerCameraYawBefore: current.player.cameraYaw ?? null,
|
|
bossPositionBefore: liveBoss.position ?? null,
|
|
bossTransformBefore: liveBoss.transform ?? null,
|
|
vowTargetingMetadata: { target: vowSetup.target, range: vowSetup.range,
|
|
radius: vowSetup.radius, targeting: vowSetup.targeting } };
|
|
result.fight.vowAttempts.push(attempt);
|
|
await page.screenshot({ path: path.join(outputDirectory, `lightning-bolt-pre-vow-${vowAttempts}.png`) });
|
|
await page.keyboard.press('Shift+Digit5');
|
|
attempt.inputAt = Date.now();
|
|
await page.waitForTimeout(65);
|
|
const afterVow = await readLiveState(page);
|
|
const afterBoss = afterVow.mobs.find((mob) => mob.id === bossId);
|
|
const cancelEvent = afterVow.presentationEvents.find((event) => event.id > presentationBefore
|
|
&& event.phase === 'cast-cancel' && event.actorGroup === 'enemy'
|
|
&& event.sourceActorId === bossId && event.abilityId === beforeCast.attackId
|
|
&& (event.sourceSpellId === 9532 || event.name === sourceLightning.name));
|
|
const vowEvents = afterVow.events.filter((event) => !eventsBefore.includes(event.id)
|
|
&& event.abilityId === vowSetup.id && event.sourceActorId === '__player__');
|
|
const schoolLock = afterBoss?.schoolLockedUntil?.nature ?? 0;
|
|
const sameObservedCastStillActive = afterBoss?.activeCast?.attackId === beforeCast.attackId
|
|
&& afterBoss.activeCast.startedAt === beforeCast.startedAt;
|
|
const sourceCastEndedOnTime = Boolean(!sameObservedCastStillActive && afterVow.now < beforeCast.completesAt);
|
|
const appliedSilence = afterBoss?.statuses.find((status) => status.kind === 'silence'
|
|
&& status.sourceAbilityId === 'aura:rom-priest-vow-of-silence'
|
|
&& status.endsAt > afterVow.now) ?? null;
|
|
const silenceAura = afterBoss?.auras.find((aura) => aura.id === 'rom-priest-vow-of-silence'
|
|
&& aura.endsAt > afterVow.now) ?? null;
|
|
const silenceEndsAt = appliedSilence?.endsAt ?? silenceAura?.endsAt ?? schoolLock;
|
|
const silenced = silenceEndsAt >= afterVow.now + 7_900;
|
|
attempt.afterReadAt = afterVow.now;
|
|
attempt.presentationSubscriptionFound = afterVow.presentationSubscriptionFound;
|
|
attempt.presentationSubscribeExportName = afterVow.presentationSubscribeExportName;
|
|
attempt.presentationExportNames = afterVow.presentationExportNames;
|
|
attempt.presentationSubscriptionCandidates = afterVow.presentationSubscriptionCandidates;
|
|
attempt.activeCastAfter = afterBoss?.activeCast ?? null;
|
|
attempt.sourceCastEndedBeforeScheduledCompletion = sourceCastEndedOnTime;
|
|
attempt.sameObservedCastStillActive = sameObservedCastStillActive;
|
|
attempt.castIdentityChangedBeforeScheduledCompletion = Boolean(afterBoss?.activeCast
|
|
&& (afterBoss.activeCast.attackId !== beforeCast.attackId
|
|
|| afterBoss.activeCast.startedAt !== beforeCast.startedAt)
|
|
&& sourceCastEndedOnTime);
|
|
attempt.targetSourceIdentityAfter = afterBoss ? { serverEntry: afterBoss.serverEntry,
|
|
runewakerNpcId: afterBoss.runewakerNpcId, creatureFamily: afterBoss.creatureFamily } : null;
|
|
attempt.targetPositionAfter = afterBoss?.position ?? null;
|
|
attempt.targetTransformAfter = afterBoss?.transform ?? null;
|
|
attempt.playerOriginBefore = current.player.position ?? null;
|
|
attempt.playerCameraYawBefore = current.player.cameraYaw ?? null;
|
|
attempt.targetMechanicImmunitiesAfter = afterBoss?.mechanicImmunities ?? [];
|
|
attempt.targetSchoolImmunitiesAfter = afterBoss?.schoolImmunities ?? [];
|
|
attempt.targetStatusesAfter = afterBoss?.statuses ?? [];
|
|
attempt.targetAurasAfter = afterBoss?.auras ?? [];
|
|
attempt.castCancelPresentationEvent = cancelEvent ?? null;
|
|
attempt.playerVowCombatEvents = vowEvents;
|
|
attempt.natureLockedUntil = schoolLock;
|
|
attempt.silenceEndsAt = silenceEndsAt;
|
|
attempt.silenceDurationMsObserved = silenceEndsAt > 0 ? silenceEndsAt - afterVow.now : 0;
|
|
attempt.silenceEvidence = { appliedMobStatus: appliedSilence, appliedMobAura: silenceAura,
|
|
schoolLockUntil: schoolLock };
|
|
attempt.silenceConfirmed = silenced;
|
|
attempt.vowPresentationEvents = afterVow.presentationEvents.filter((event) => event.id > presentationBefore
|
|
&& event.actorGroup === 'player' && event.sourceActorId === '__player__'
|
|
&& event.abilityId === vowSetup.id);
|
|
attempt.presentationEventsAfterInput = afterVow.presentationEvents.filter((event) => event.id > presentationBefore);
|
|
attempt.vowEffectEvents = afterVow.events.filter((event) => event.abilityId === vowSetup.id
|
|
&& event.sourceActorId === '__player__' && event.occurredAt >= attempt.inputAt);
|
|
attempt.confirmedEffectTargets = [{ actorId: afterBoss?.id ?? null,
|
|
status: appliedSilence, aura: silenceAura }].filter((effect) => effect.status || effect.aura);
|
|
await page.screenshot({ path: path.join(outputDirectory, `lightning-bolt-post-vow-${vowAttempts}.png`) });
|
|
current = afterVow;
|
|
if (sourceCastEndedOnTime && silenced && vowEvents.some((event) => event.targetActorId === bossId)) {
|
|
result.fight.vowSuccess = true;
|
|
result.fight.vowSuccessAt = Math.min(...vowEvents.map((event) => event.occurredAt));
|
|
result.fight.silenceEndsAt = silenceEndsAt;
|
|
result.fight.cancellationEvidence = { liveSourceCastBefore: beforeCast,
|
|
activeCastAfter: afterBoss?.activeCast ?? null, sourceCastEndedOnTime,
|
|
cancelPresentationEvent: cancelEvent ?? null,
|
|
statusAppliedToBoss: appliedSilence, silenceEndsAt };
|
|
result.fight.silenceWindow = { startedAt: result.fight.vowSuccessAt, expectedEndsAt: silenceEndsAt,
|
|
lightningBoltAttackId: sourceLightning.id,
|
|
lightningBoltCountAtStart: afterBoss.abilityCastCounts?.[sourceLightning.id] ?? null,
|
|
enemyLightningBoltStarts: [],
|
|
statusSamples: [{ at: afterVow.now, active: Boolean(appliedSilence && appliedSilence.endsAt > afterVow.now),
|
|
endsAt: appliedSilence?.endsAt ?? silenceEndsAt, activeCast: afterBoss?.activeCast ?? null }],
|
|
nonPhysicalCastSamplesDuringActiveStatus: appliedSilence && afterBoss?.activeCast
|
|
&& afterBoss.activeCast.school !== 'physical'
|
|
? [{ at: afterVow.now, statusEndsAt: appliedSilence.endsAt, activeCast: afterBoss.activeCast }]
|
|
: [],
|
|
sourceNatureCastResumptionsAfterExpiry: [] };
|
|
observedCastCountAtWindowStart = afterBoss.abilityCastCounts?.[sourceLightning.id] ?? null;
|
|
await page.keyboard.press('F2');
|
|
result.fight.partyDefendInput = { at: Date.now(), key: 'F2', command: 'Defend',
|
|
reason: 'hold the actual encounter while observing Vow silence duration' };
|
|
}
|
|
}
|
|
|
|
if (result.fight.vowSuccess && current.now < result.fight.silenceEndsAt) {
|
|
const silenceSampleBoss = current.mobs.find((mob) => mob.id === bossId);
|
|
const silenceSampleCast = silenceSampleBoss?.activeCast;
|
|
const activeSilenceStatus = silenceSampleBoss?.statuses.find((status) => status.kind === 'silence'
|
|
&& status.sourceAbilityId === 'aura:rom-priest-vow-of-silence' && status.endsAt > current.now) ?? null;
|
|
result.fight.silenceWindow.statusSamples.push({ at: current.now,
|
|
active: Boolean(activeSilenceStatus), endsAt: activeSilenceStatus?.endsAt ?? null,
|
|
activeCast: silenceSampleCast ?? null });
|
|
if (activeSilenceStatus && silenceSampleCast && silenceSampleCast.school !== 'physical') {
|
|
result.fight.silenceWindow.nonPhysicalCastSamplesDuringActiveStatus.push({ at: current.now,
|
|
statusEndsAt: activeSilenceStatus.endsAt, activeCast: silenceSampleCast });
|
|
}
|
|
const startsDuringSilence = current.presentationEvents.filter((event) => event.actorGroup === 'enemy'
|
|
&& event.sourceActorId === bossId && event.abilityId === result.fight.silenceWindow.lightningBoltAttackId
|
|
&& event.phase === 'cast-start' && event.occurredAt >= result.fight.vowSuccessAt
|
|
&& event.occurredAt < result.fight.silenceEndsAt);
|
|
result.fight.silenceWindow.enemyLightningBoltStarts = startsDuringSilence;
|
|
const sampleCount = silenceSampleBoss?.abilityCastCounts?.[result.fight.silenceWindow.lightningBoltAttackId] ?? null;
|
|
result.fight.silenceWindow.lightningBoltCountAtLastSample = sampleCount;
|
|
if (sampleCount !== observedCastCountAtWindowStart || silenceSampleCast?.attackId === result.fight.silenceWindow.lightningBoltAttackId) {
|
|
result.fight.silenceWindow.suppressionFailedAt = current.now;
|
|
result.fight.outcome = 'lightning-bolt-started-during-silence';
|
|
break;
|
|
}
|
|
const injured = current.party.filter((member) => member.health > 0
|
|
&& member.health < member.maxHealth * 0.72 && member.position)
|
|
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0] ?? null;
|
|
const selfInjured = current.player.health < current.player.maxHealth * 0.72;
|
|
if ((injured || selfInjured) && result.actionPlan.healing && Date.now() - nextHealAt > 1_100) {
|
|
const self = selfInjured && (!injured || current.player.health / current.player.maxHealth
|
|
< injured.health / injured.maxHealth);
|
|
if (self) await selectPlayerFrame(page, result.profile.name);
|
|
else await selectPartyMember(page, injured);
|
|
const cast = await performCast(page, result.actionPlan.healing, 'healing', self ? '__player__' : injured.id,
|
|
self ? 'Boss Testing Vow silence window survival' : `Boss Testing triage for ${injured.name}`);
|
|
result.fight.healingActions.push(cast);
|
|
nextHealAt = Date.now();
|
|
}
|
|
await page.waitForTimeout(100);
|
|
continue;
|
|
}
|
|
|
|
if (result.fight.vowSuccess && !result.fight.silenceWindow.verifiedAt
|
|
&& current.now >= result.fight.silenceEndsAt) {
|
|
const finalBoss = current.mobs.find((mob) => mob.id === bossId);
|
|
const durationStarts = current.presentationEvents.filter((event) => event.actorGroup === 'enemy'
|
|
&& event.sourceActorId === bossId && event.abilityId === result.fight.silenceWindow.lightningBoltAttackId
|
|
&& event.phase === 'cast-start' && event.occurredAt >= result.fight.vowSuccessAt
|
|
&& event.occurredAt < result.fight.silenceEndsAt);
|
|
result.fight.silenceWindow.enemyLightningBoltStarts = durationStarts;
|
|
result.fight.silenceWindow.lightningBoltCountAtEnd = finalBoss?.abilityCastCounts?.[result.fight.silenceWindow.lightningBoltAttackId] ?? null;
|
|
result.fight.silenceWindow.verifiedAt = current.now;
|
|
result.fight.silenceWindow.durationMsObserved = Math.min(current.now, result.fight.silenceEndsAt)
|
|
- result.fight.vowSuccessAt;
|
|
result.fight.silenceWindow.verificationLagMs = current.now - result.fight.silenceEndsAt;
|
|
result.fight.silenceWindow.nonPhysicalCastStartsDuringSilence = result.fight.castTimeline.filter(({ activeCast }) => (
|
|
activeCast && activeCast.school !== 'physical'
|
|
&& activeCast.startedAt >= result.fight.vowSuccessAt
|
|
&& activeCast.startedAt < result.fight.silenceEndsAt
|
|
));
|
|
result.fight.silenceWindow.fullSilenceSuppressionPassed = result.fight.silenceWindow.statusSamples.some((sample) => sample.active)
|
|
&& result.fight.silenceWindow.nonPhysicalCastSamplesDuringActiveStatus.length === 0
|
|
&& result.fight.silenceWindow.nonPhysicalCastStartsDuringSilence.length === 0;
|
|
result.fight.silenceWindow.suppressedNextCast = durationStarts.length === 0
|
|
&& result.fight.silenceWindow.lightningBoltCountAtEnd === observedCastCountAtWindowStart;
|
|
if (!result.fight.silenceWindow.suppressedNextCast) {
|
|
result.fight.outcome = 'lightning-bolt-not-suppressed-through-silence';
|
|
break;
|
|
}
|
|
await page.keyboard.press('F1');
|
|
result.fight.partyAttackResumedAt = Date.now();
|
|
}
|
|
|
|
if (result.fight.silenceWindow?.verifiedAt && current.now >= result.fight.silenceEndsAt) {
|
|
const expiredWindowBoss = current.mobs.find((mob) => mob.id === bossId);
|
|
const resumedCast = expiredWindowBoss?.activeCast;
|
|
const resumedAttack = resumedCast && expiredWindowBoss.attacks.find((attack) => attack.id === resumedCast.attackId);
|
|
if (resumedCast && resumedCast.startedAt >= result.fight.silenceEndsAt
|
|
&& (resumedCast.school === 'nature' || resumedAttack?.school === 'nature')
|
|
&& !result.fight.silenceWindow.sourceNatureCastResumptionsAfterExpiry.some((entry) => (
|
|
entry.activeCast.attackId === resumedCast.attackId && entry.activeCast.startedAt === resumedCast.startedAt
|
|
))) {
|
|
result.fight.silenceWindow.sourceNatureCastResumptionsAfterExpiry.push({ at: current.now,
|
|
activeCast: resumedCast, sourceAttack: resumedAttack ?? null });
|
|
}
|
|
}
|
|
|
|
const injured = current.party.filter((member) => member.health > 0
|
|
&& member.health < member.maxHealth * 0.72 && member.position)
|
|
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0] ?? null;
|
|
const selfInjured = current.player.health < current.player.maxHealth * 0.72;
|
|
if ((injured || selfInjured) && result.actionPlan.healing && Date.now() - nextHealAt > 1_100) {
|
|
const self = selfInjured && (!injured || current.player.health / current.player.maxHealth
|
|
< injured.health / injured.maxHealth);
|
|
if (self) await selectPlayerFrame(page, result.profile.name);
|
|
else await selectPartyMember(page, injured);
|
|
const cast = await performCast(page, result.actionPlan.healing, 'healing', self ? '__player__' : injured.id,
|
|
self ? 'Boss Testing live-fight survival' : `Boss Testing triage for ${injured.name}`);
|
|
result.fight.healingActions.push(cast);
|
|
nextHealAt = Date.now();
|
|
continue;
|
|
}
|
|
|
|
if (result.fight.vowSuccess && result.fight.silenceWindow.suppressedNextCast
|
|
&& result.actionPlan.damage && current.player.selectedTargetId === bossId
|
|
&& !current.player.activeCast && current.player.globalCooldownEndsAt <= current.now
|
|
&& Date.now() - nextDamageAt > 1_300) {
|
|
const cast = await performCast(page, result.actionPlan.damage, 'damage', bossId,
|
|
'normal keyboard damage in Lady Anacondra Boss Testing');
|
|
result.fight.damageActions.push(cast);
|
|
nextDamageAt = Date.now();
|
|
continue;
|
|
}
|
|
await page.waitForTimeout(90);
|
|
}
|
|
|
|
if (result.fight.outcome === 'in-progress') result.fight.outcome = '80-second-live-fight-budget-ended';
|
|
const finalState = await readLiveState(page);
|
|
const finalBoss = finalState.mobs.find((mob) => mob.id === bossId) ?? null;
|
|
result.fight.endedAt = Date.now();
|
|
result.fight.durationMs = result.fight.endedAt - result.fight.startedAt;
|
|
result.fight.finalVitals = { player: playerVitals(finalState), party: partyVitals(finalState) };
|
|
result.fight.bossAtFinalRead = finalBoss;
|
|
result.fight.bossFate = finalBoss?.dead || finalBoss?.health <= 0 ? 'defeated'
|
|
: finalBoss ? 'alive-at-budget-end' : 'despawned-or-unavailable';
|
|
result.fight.partySurvivors = finalState.party.filter((member) => member.health > 0).map((member) => member.name);
|
|
result.fight.partyDeaths = finalState.party.filter((member) => member.health <= 0).map((member) => member.name);
|
|
result.fight.playerSurvived = finalState.player.health > 0;
|
|
result.fight.finalPlayerTargetId = finalState.player.selectedTargetId;
|
|
result.fight.experienceBefore = result.fight.preFight.experience;
|
|
result.fight.experienceAfter = finalState.player.experience;
|
|
result.fight.noExperienceAwardObserved = finalState.player.experience === result.fight.preFight.experience;
|
|
result.fight.noLootObserved = Boolean(finalBoss
|
|
&& !finalBoss.hasLoot && finalBoss.pendingLootCount === 0);
|
|
result.fight.finalEvents = finalState.events;
|
|
await page.screenshot({ path: path.join(outputDirectory, 'fight-end.png') });
|
|
return finalState;
|
|
}
|
|
|
|
(async () => {
|
|
let runtime = null;
|
|
let browser = null;
|
|
const pageErrors = [];
|
|
const consoleErrors = [];
|
|
const assetFiles = fs.readdirSync('dist/assets')
|
|
.filter((file) => /^(?:index|GameRuntime|combatStore|partyStore|PartyFrames|romAbilityCatalog)-.*\.js$/.test(file)).sort();
|
|
const cssAssetFiles = fs.readdirSync('dist/assets')
|
|
.filter((file) => /^index-.*\.css$/.test(file)).sort();
|
|
const assetSnapshotAtStart = [...assetFiles, ...cssAssetFiles].map((file) => {
|
|
const absolute = path.resolve('dist/assets', file);
|
|
const bytes = fs.readFileSync(absolute);
|
|
return { file, bytes: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex') };
|
|
});
|
|
const moduleUrls = assetFiles.map((file) => `/assets/${file}`);
|
|
const result = {
|
|
status: 'running',
|
|
buildCapturedAtScriptStart: new Date().toISOString(),
|
|
snapshotKind: 'final production dist; dedicated Boss Testing fight only',
|
|
outputDirectory,
|
|
assetSnapshotAtStart,
|
|
liveFightBudgetMs,
|
|
scope: { mode: 'Boss Testing', boss: 'Lady Anacondra', mechanic: 'Vow of Silence (DBC 490275) interrupts source Lightning Bolt (9532)',
|
|
combatStoreWritesDuringLiveFight: false, stagedEnemyCast: false, allowedSetup: ['UI-created RoM Priest', 'level 30', 'RoM secondary class Knight',
|
|
'Vow bound through Ability controls UI to Shift+Digit5'], lootExpected: false, readinessChanges: false },
|
|
profile: null,
|
|
partyAtEntry: [],
|
|
vowSetup: null,
|
|
actionPlan: null,
|
|
fight: { outcome: 'in-progress', vowAttempts: [], vowSuccess: false,
|
|
damageActions: [], healingActions: [], samples: [], castTimeline: [], presentationEvents: [], pageErrors: [] },
|
|
pageErrors,
|
|
consoleErrors,
|
|
};
|
|
try {
|
|
if (assetFiles.length < 3) throw new Error(`Expected final production assets are missing: ${assetFiles.join(', ')}`);
|
|
const { createGameServer } = await import('../server/server.mjs');
|
|
runtime = createGameServer({ dataDir: dataDirectory, enableShelvedModes: false });
|
|
await new Promise((resolve, reject) => {
|
|
runtime.server.once('error', reject);
|
|
runtime.server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
const address = runtime.server.address();
|
|
browser = await chromium.launch({ channel: 'msedge', headless: true });
|
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
|
page.setDefaultTimeout(120_000);
|
|
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() });
|
|
});
|
|
await page.addInitScript(({ urls }) => {
|
|
window.vowFinalPresentationEvents = [];
|
|
window.vowFinalStores = async () => {
|
|
const modules = await Promise.all(urls.map((url) => import(url)));
|
|
const namedExports = modules.flatMap((module) => Object.entries(module));
|
|
const exports = namedExports.map(([, value]) => value);
|
|
const stores = exports.filter((value) => value && typeof value.getState === 'function');
|
|
const subscribe = namedExports.find(([name, value]) => name === 'subscribeCombatPresentation'
|
|
&& typeof value === 'function');
|
|
window.vowFinalPresentationExportNames = namedExports.map(([name]) => name)
|
|
.filter((name) => name.toLowerCase().includes('presentation'));
|
|
if (subscribe && !window.vowFinalUnsubscribe) {
|
|
window.vowFinalPresentationSubscriptionFound = true;
|
|
window.vowFinalPresentationSubscribeExportName = subscribe[0];
|
|
window.vowFinalUnsubscribe = subscribe[1]((message) => {
|
|
if (message?.kind === 'event') window.vowFinalPresentationEvents.push(message.event);
|
|
if (window.vowFinalPresentationEvents.length > 500) window.vowFinalPresentationEvents.shift();
|
|
});
|
|
}
|
|
const transformExport = namedExports.find(([, value]) => {
|
|
if (typeof value !== 'function') return false;
|
|
const source = Function.prototype.toString.call(value);
|
|
return source.includes('.mounted') && source.includes('.value') && source.includes('position:')
|
|
&& source.includes('yaw:') && source.includes('null');
|
|
});
|
|
const partyPositionExport = namedExports.find(([, value]) => {
|
|
if (typeof value !== 'function') return false;
|
|
const source = Function.prototype.toString.call(value);
|
|
return /\.get\(/.test(source)
|
|
&& /return\s+[A-Za-z_$][\w$]*\s*\?\s*\[\s*\.\.\.[A-Za-z_$][\w$]*\s*\]\s*:\s*null/.test(source);
|
|
});
|
|
const presentationSubscriptionCandidates = namedExports.filter(([, value]) => {
|
|
if (typeof value !== 'function') return false;
|
|
const source = Function.prototype.toString.call(value);
|
|
return source.includes('.add(') && source.includes('.delete(') && source.includes('=>');
|
|
}).map(([name, value]) => ({ name, source: Function.prototype.toString.call(value) }));
|
|
window.vowFinalPresentationSubscriptionCandidates = presentationSubscriptionCandidates;
|
|
return {
|
|
getMobPosition: transformExport?.[1] ? (id) => transformExport[1](id)?.position ?? null : null,
|
|
getMobTransform: transformExport?.[1] ?? null,
|
|
getPartyPosition: partyPositionExport?.[1] ?? null,
|
|
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 });
|
|
await page.goto(`http://127.0.0.1:${address.port}`, { 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();
|
|
await page.locator('.class-option').filter({ hasText: /^Priest/ }).click();
|
|
await page.getByRole('button', { name: /Customize & name/ }).click();
|
|
await page.getByLabel('Character name').fill('VowLiveFinalQA');
|
|
await page.getByRole('button', { name: /Create character/ }).click();
|
|
await page.locator('.character-select-screen').waitFor();
|
|
const profile = await page.evaluate(async () => {
|
|
const { shell } = await window.vowFinalStores();
|
|
const state = shell.getState();
|
|
const character = state.characters.find((entry) => entry.id === state.selectedCharacterId);
|
|
if (!character || character.classId !== 'rom-priest') throw new Error('UI-created RoM Priest profile is missing.');
|
|
shell.setState({ activeCharacter: character });
|
|
shell.getState().saveActiveProgression(30, 0, character.talentRanks);
|
|
shell.setState({ activeCharacter: null });
|
|
if (!shell.getState().chooseRomSecondaryClass('rom-knight')) throw new Error('Priest/Knight pairing was not accepted.');
|
|
const updated = shell.getState().characters.find((entry) => entry.id === character.id);
|
|
return { id: updated.id, name: updated.name, classId: updated.classId,
|
|
secondaryClassId: updated.secondaryClassId, level: updated.level, categoryId: updated.categoryId };
|
|
});
|
|
assert.equal(profile.classId, 'rom-priest');
|
|
assert.equal(profile.secondaryClassId, 'rom-knight');
|
|
assert.equal(profile.level, 30);
|
|
result.profile = profile;
|
|
await page.waitForTimeout(1_000);
|
|
await page.screenshot({ path: path.join(outputDirectory, 'roster-priest-knight-level-30.png') });
|
|
await page.locator('.roster-enter').click();
|
|
await page.locator('.main-menu-screen').waitFor();
|
|
await page.getByRole('button', { name: /^GM Menu/ }).click();
|
|
await page.getByRole('button', { name: /Boss Testing/ }).click();
|
|
await page.getByRole('heading', { name: 'Boss Testing', exact: true }).waitFor();
|
|
const picker = page.getByLabel(/Choose a boss/);
|
|
const option = await picker.locator('option').evaluateAll((options) => options
|
|
.find((entry) => entry.textContent.startsWith('Lady Anacondra ·'))?.value ?? null);
|
|
assert(option, 'Lady Anacondra appears in the dedicated Boss Testing catalog.');
|
|
await picker.selectOption(option);
|
|
const selectedArticle = page.locator('.boss-testing-selected');
|
|
await selectedArticle.getByRole('heading', { name: 'Lady Anacondra', exact: true }).waitFor();
|
|
await page.screenshot({ path: path.join(outputDirectory, 'boss-testing-lady-selection.png') });
|
|
await page.getByRole('button', { name: 'Test selected boss', exact: true }).click();
|
|
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
|
|
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 180_000 }).catch(() => {});
|
|
await page.waitForFunction(async () => {
|
|
const { game, combat } = await window.vowFinalStores();
|
|
const current = game.getState();
|
|
const boss = current.bossTest;
|
|
return current.assetStatus === 'ready' && boss?.name === 'Lady Anacondra'
|
|
&& boss.runtimeIds.every((id) => combat.getState().mobs[id]);
|
|
}, null, { timeout: 240_000 });
|
|
await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached', timeout: 15_000 }).catch(() => {});
|
|
await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur());
|
|
let entry = await readLiveState(page, true);
|
|
assert.equal(entry.game.bossTest?.name, 'Lady Anacondra');
|
|
assert.equal(entry.game.gmTestMode, true, 'Selected room is actual Boss Testing, through its UI flow.');
|
|
assert.equal(entry.player.classId, 'rom-priest');
|
|
assert.equal(entry.player.secondaryClassId, 'rom-knight');
|
|
assert.equal(entry.party.length, 4, 'Boss Testing generated four owned NPC companions.');
|
|
assert(entry.party.every((member) => member.classId.startsWith('rom-') && member.statRuleset === 'rom'),
|
|
'All four NPCs have native RoM identities.');
|
|
result.partyAtEntry = partyVitals(entry);
|
|
result.partyIdentityAtEntry = entry.party.map((member) => ({ id: member.id, name: member.name,
|
|
classId: member.classId, secondaryClassId: member.secondaryClassId, role: member.role,
|
|
statRuleset: member.statRuleset }));
|
|
await page.screenshot({ path: path.join(outputDirectory, 'lady-anacondra-before-vow.png') });
|
|
result.vowSetup = await assignVowThroughAbilityControls(page, outputDirectory);
|
|
entry = await readLiveState(page, true);
|
|
result.actionPlan = actionPlanFromSnapshot(entry, result.vowSetup.id);
|
|
if (!result.actionPlan.healing) throw new Error('No normal keyboard-bound Priest healing action is available.');
|
|
const boss = entry.mobs.find((mob) => mob.id === entry.game.bossTest.runtimeIds[0]);
|
|
if (!boss || !boss.boss || boss.name !== 'Lady Anacondra') throw new Error('Boss Testing did not spawn the expected boss.');
|
|
result.fight.preFight = { at: entry.now, bossId: boss.id, boss: { id: boss.id, name: boss.name,
|
|
level: boss.level, health: boss.health, maxHealth: boss.maxHealth,
|
|
attacks: boss.attacks, abilityCastCounts: boss.abilityCastCounts },
|
|
player: playerVitals(entry), party: partyVitals(entry), game: entry.game,
|
|
experience: entry.player.experience };
|
|
await page.screenshot({ path: path.join(outputDirectory, 'lady-anacondra-fight-start.png') });
|
|
await runFight(page, result, result.vowSetup, entry);
|
|
assert.equal(result.fight.vowSuccess, true,
|
|
'A live scheduled Lightning Bolt ended early and Vow applied its full-duration silence status.');
|
|
assert.equal(result.fight.silenceWindow.suppressedNextCast, true,
|
|
'No subsequent Lightning Bolt began during the observed silence window.');
|
|
assert.equal(result.fight.silenceWindow.fullSilenceSuppressionPassed, true,
|
|
'No nonphysical Lady Anacondra cast became active during Vow silence.');
|
|
assert.equal(result.fight.noLootObserved, true, 'Boss Testing did not create boss loot.');
|
|
assert.equal(result.fight.noExperienceAwardObserved, true, 'Boss Testing did not award XP.');
|
|
assert.deepEqual(pageErrors, [], `No browser page exceptions: ${JSON.stringify(pageErrors)}`);
|
|
result.status = 'complete';
|
|
result.pageErrors = pageErrors;
|
|
result.consoleErrors = consoleErrors;
|
|
result.fight.pageErrors = pageErrors;
|
|
result.completedAt = new Date().toISOString();
|
|
fs.writeFileSync(path.join(outputDirectory, 'results.json'), `${JSON.stringify(result, null, 2)}\n`);
|
|
console.log(`Live Vow of Silence QA complete: ${outputDirectory}`);
|
|
} catch (error) {
|
|
result.status = 'failed';
|
|
result.error = error.stack ?? error.message;
|
|
result.pageErrors = pageErrors;
|
|
result.consoleErrors = consoleErrors;
|
|
result.fight.pageErrors = pageErrors;
|
|
result.completedAt = new Date().toISOString();
|
|
if (browser) {
|
|
const contexts = browser.contexts();
|
|
const page = contexts[0]?.pages()[0];
|
|
if (page) await page.screenshot({ path: path.join(outputDirectory, 'failure.png') }).catch(() => {});
|
|
if (page) result.failureBodyText = (await page.locator('body').innerText().catch(() => '')).slice(-3_000);
|
|
}
|
|
fs.writeFileSync(path.join(outputDirectory, 'results.json'), `${JSON.stringify(result, null, 2)}\n`);
|
|
console.error(`Live Vow of Silence QA failed: ${outputDirectory}`);
|
|
throw error;
|
|
} finally {
|
|
if (browser) await browser.close();
|
|
if (runtime) await new Promise((resolve) => runtime.server.close(resolve));
|
|
const target = fs.realpathSync(dataDirectory);
|
|
if (path.dirname(target) !== fs.realpathSync(os.tmpdir())
|
|
|| !path.basename(target).startsWith('healer-man-vow-live-final-')) {
|
|
throw new Error('Unsafe temporary playtest database cleanup path.');
|
|
}
|
|
fs.rmSync(target, { recursive: true, force: true });
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error.stack ?? error.message);
|
|
process.exitCode = 1;
|
|
});
|