forked from phenom/healer-man
862 lines
49 KiB
JavaScript
862 lines
49 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 fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { spawnSync } = require('node:child_process');
|
|
const {
|
|
DEFAULT_TEST_DUNGEON_ID,
|
|
DEFAULT_TEST_DUNGEON_TITLE,
|
|
selectDefaultTestDungeon,
|
|
openSelectedDungeonRoles,
|
|
} = require('./playtest-defaults.cjs');
|
|
|
|
// Static preparation is safe at any time. A live run requires --run-live and
|
|
// must use the normal Cellblock Rush picker and player controls.
|
|
const projectRoot = path.resolve(__dirname, '..');
|
|
const artifactRoot = path.join(projectRoot, 'artifacts', 'playtest', 'coa-guardian-formations');
|
|
const stamp = new Date().toISOString().replace(/[-:.]/g, '').replace('T', '-').replace('Z', '');
|
|
const outputDirectory = path.join(artifactRoot, stamp);
|
|
const liveRequested = process.argv.includes('--run-live');
|
|
const prepareOnly = process.argv.includes('--prepare-only');
|
|
const routeSecondsArg = process.argv.find((value) => value.startsWith('--route-seconds='));
|
|
const routeSeconds = Math.min(180, Math.max(30, Number(routeSecondsArg?.split('=')[1] ?? 180)));
|
|
const distArg = process.argv.find((value) => value.startsWith('--dist='));
|
|
const preparedPartyPath = path.join(outputDirectory, 'prepared-party.json');
|
|
const resultPath = path.join(outputDirectory, 'results.json');
|
|
const PLAYTEST_SEED = 'coa-guardian-formations-cellblock-live-v1';
|
|
const FORMATION_IDS = Object.freeze({
|
|
tower: 'coa:800317:effect:0:aura:87',
|
|
line: 'coa:803130:effect:0:aura:232',
|
|
paragonReady: 'coa:707138:paragon-smash-ready',
|
|
paragonSmash: 'coa:803432:paragon-smash-buff',
|
|
});
|
|
|
|
function writeJson(filename, value) {
|
|
fs.writeFileSync(filename, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
function prepareFirstPartyParty() {
|
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
|
const runnerPath = path.join(outputDirectory, 'prepare-first-party.mts');
|
|
const runnerSource = `
|
|
import { writeFile } from 'node:fs/promises';
|
|
import { loadCoaFirstPartyBuilds, createCoaFirstPartyCompanions } from '../../../../src/game/benchmark/coaFirstParty';
|
|
import { runtimeNormalizeTalentRanks, runtimeTalentNodeById, runtimeUnspentCoaEssence } from '../../../../src/game/talentRuntimeCatalog';
|
|
|
|
const builds = await loadCoaFirstPartyBuilds(60);
|
|
const companions = createCoaFirstPartyCompanions(builds, 0, ${JSON.stringify(PLAYTEST_SEED)});
|
|
const guardian = builds[0];
|
|
if (!guardian || guardian.id !== 'balance-coa-guardian-tank-vanguard-level-60') throw new Error('Canonical level-60 Guardian/Vanguard build is missing.');
|
|
|
|
const requiredNodes = {
|
|
paragon: 'coa-entry-29969',
|
|
heavyBlow: 'coa-entry-31169',
|
|
brace: 'coa-entry-30057',
|
|
footmansCalling: 'coa-entry-4019',
|
|
vanguardsShield: 'coa-entry-34417',
|
|
};
|
|
const desired = { ...guardian.talentRanks, [requiredNodes.paragon]: 1, [requiredNodes.heavyBlow]: 1 };
|
|
let playerTalentRanks = runtimeNormalizeTalentRanks('guardian', 60, desired);
|
|
const hasRequestedMechanics = (ranks) => ranks[requiredNodes.paragon] === 1 && ranks[requiredNodes.heavyBlow] === 1
|
|
&& ranks[requiredNodes.brace] > 0 && ranks[requiredNodes.footmansCalling] > 0;
|
|
const removedRanks = [];
|
|
|
|
// If the canonical role-weighted build spent its full talent essence budget,
|
|
// free only sourced talent essence from non-required ranks. Every intermediate
|
|
// candidate is normalized by the runtime allocator before it can be selected.
|
|
if (!hasRequestedMechanics(playerTalentRanks)) {
|
|
const paragonTree = runtimeTalentNodeById(requiredNodes.paragon)?.treeId;
|
|
const removable = Object.entries(guardian.talentRanks).flatMap(([nodeId, rank]) => {
|
|
const node = runtimeTalentNodeById(nodeId);
|
|
if (!node || rank < 1 || Object.values(requiredNodes).includes(nodeId) || !(node.talentEssenceCost > 0)) return [];
|
|
return [{ nodeId, node, rank }];
|
|
}).sort((left, right) => Number(left.node.treeId === paragonTree) - Number(right.node.treeId === paragonTree)
|
|
|| right.node.row - left.node.row || left.nodeId.localeCompare(right.nodeId));
|
|
const requestedRanks = { ...desired };
|
|
for (const candidate of removable) {
|
|
if (hasRequestedMechanics(playerTalentRanks)) break;
|
|
requestedRanks[candidate.nodeId] = Math.max(0, candidate.rank - 1);
|
|
removedRanks.push({ nodeId: candidate.nodeId, name: candidate.node.name, fromRank: candidate.rank, toRank: candidate.rank - 1 });
|
|
playerTalentRanks = runtimeNormalizeTalentRanks('guardian', 60, requestedRanks);
|
|
}
|
|
}
|
|
|
|
const allocationKind = hasRequestedMechanics(playerTalentRanks)
|
|
? (removedRanks.length ? 'explicit-pre-entry-legal-paragon-fixture' : 'generated-role-weighted-build')
|
|
: 'generated-role-weighted-build-paragon-unavailable';
|
|
if (JSON.stringify(runtimeNormalizeTalentRanks('guardian', 60, playerTalentRanks)) !== JSON.stringify(playerTalentRanks)) {
|
|
throw new Error('Prepared Guardian talent ranks are not normalized by the runtime allocator.');
|
|
}
|
|
const companionClassIds = companions.map((member) => member.classId);
|
|
if (JSON.stringify(companionClassIds) !== JSON.stringify(['witch-doctor', 'barbarian', 'ranger', 'pyromancer'])) {
|
|
throw new Error('The configured CoA companion factory returned a different first-party roster.');
|
|
}
|
|
const members = builds.map((build, slot) => ({
|
|
slot,
|
|
buildId: build.id,
|
|
classId: build.classId,
|
|
specialization: build.orientation,
|
|
specializationId: build.classId + ':' + build.orientation.toLowerCase().replace(/\\s+/g, '-'),
|
|
role: build.role,
|
|
level: build.level,
|
|
talentRanks: slot === 0 ? playerTalentRanks : build.talentRanks,
|
|
}));
|
|
const output = {
|
|
schemaVersion: 1,
|
|
scope: 'isolated pre-entry level-60 CoA first-party setup; no readiness or random-pool changes',
|
|
activeSlot: 0,
|
|
partySeed: ${JSON.stringify(PLAYTEST_SEED)},
|
|
dungeonId: ${JSON.stringify(DEFAULT_TEST_DUNGEON_ID)},
|
|
partyBuildIds: builds.map((build) => build.id),
|
|
members,
|
|
companions,
|
|
playerTalentAllocation: {
|
|
kind: allocationKind,
|
|
canonicalBuildId: guardian.id,
|
|
canonicalTalentRanks: guardian.talentRanks,
|
|
selectedTalentRanks: playerTalentRanks,
|
|
changedRanks: [...new Set([...Object.keys(guardian.talentRanks), ...Object.keys(playerTalentRanks)])]
|
|
.filter((nodeId) => (guardian.talentRanks[nodeId] ?? 0) !== (playerTalentRanks[nodeId] ?? 0))
|
|
.map((nodeId) => ({ nodeId, fromRank: guardian.talentRanks[nodeId] ?? 0, toRank: playerTalentRanks[nodeId] ?? 0 })),
|
|
freedRanks: removedRanks,
|
|
requiredNodes: Object.fromEntries(Object.entries(requiredNodes).map(([key, nodeId]) => [key, playerTalentRanks[nodeId] ?? 0])),
|
|
unspentCoaEssence: runtimeUnspentCoaEssence(60, playerTalentRanks, 'guardian'),
|
|
runtimeNormalized: true,
|
|
},
|
|
};
|
|
await writeFile(${JSON.stringify(preparedPartyPath)}, JSON.stringify(output, null, 2) + '\\n');
|
|
console.log(JSON.stringify({ output: ${JSON.stringify(preparedPartyPath)}, allocationKind,
|
|
requiredNodes: output.playerTalentAllocation.requiredNodes,
|
|
freedRanks: removedRanks.length, partyBuildIds: output.partyBuildIds,
|
|
companionClassIds }));
|
|
`;
|
|
fs.writeFileSync(runnerPath, `${runnerSource.trim()}\n`);
|
|
const viteNodeCli = path.join(projectRoot, 'node_modules', 'vite-node', 'dist', 'cli.mjs');
|
|
const child = spawnSync(process.execPath, [viteNodeCli, '--mode', 'balance-lab', runnerPath], {
|
|
cwd: projectRoot,
|
|
encoding: 'utf8',
|
|
maxBuffer: 8 * 1024 * 1024,
|
|
});
|
|
if (child.status !== 0) {
|
|
throw new Error(`CoA first-party preparation failed.\n${child.stdout}\n${child.stderr}`);
|
|
}
|
|
if (!fs.existsSync(preparedPartyPath)) throw new Error('CoA preparation did not write the configured party fixture.');
|
|
const prepared = JSON.parse(fs.readFileSync(preparedPartyPath, 'utf8'));
|
|
assert.equal(prepared.partyBuildIds.length, 5);
|
|
assert.equal(prepared.companions.length, 4);
|
|
return { prepared, output: child.stdout.trim(), runnerPath };
|
|
}
|
|
|
|
async function productionModules() {
|
|
const requestedDist = distArg ? distArg.slice('--dist='.length) : 'dist';
|
|
if (!requestedDist || path.isAbsolute(requestedDist)) {
|
|
throw new Error('--dist must name a non-empty path relative to the project root.');
|
|
}
|
|
const dist = path.resolve(projectRoot, requestedDist);
|
|
const relativeDist = path.relative(projectRoot, dist);
|
|
if (!relativeDist || relativeDist === '..' || relativeDist.startsWith(`..${path.sep}`) || path.isAbsolute(relativeDist)) {
|
|
throw new Error(`The selected build directory must resolve within the project root: ${requestedDist}`);
|
|
}
|
|
const reportPath = path.join(dist, 'bundle-module-report.json');
|
|
if (!fs.existsSync(reportPath)) throw new Error(`A ready production build is required before --run-live: ${reportPath}`);
|
|
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
const requiredOwners = ['src/game/combatStore.ts', 'src/game/partyStore.ts', 'src/game/store.ts', 'src/app/shellStore.ts'];
|
|
for (const owner of requiredOwners) {
|
|
if (!report.chunks?.some((chunk) => chunk.modules?.some((modulePath) => (
|
|
modulePath.replace(/\\/g, '/').split('?')[0].endsWith(owner)
|
|
)))) throw new Error(`The ready build module report does not contain ${owner}.`);
|
|
}
|
|
const dungeonRunChunk = report.chunks.find((chunk) => chunk.modules?.some((modulePath) => (
|
|
modulePath.replace(/\\/g, '/').split('?')[0].endsWith('src/game/dungeonRunStore.ts')
|
|
)));
|
|
if (!dungeonRunChunk?.file) throw new Error('The ready build module report does not identify dungeonRunStore.');
|
|
const dungeonRunFile = path.basename(dungeonRunChunk.file);
|
|
if (!fs.existsSync(path.join(dist, 'assets', dungeonRunFile))) {
|
|
throw new Error(`The ready build report points to a missing dungeon run chunk: ${dungeonRunFile}`);
|
|
}
|
|
const catalogChunk = report.chunks.find((chunk) => chunk.modules?.some((modulePath) => (
|
|
modulePath.replace(/\\/g, '/').split('?')[0].endsWith('src/game/abilityCatalog.ts')
|
|
)));
|
|
if (!catalogChunk?.file) throw new Error('The ready build report does not identify the abilityCatalog owner chunk.');
|
|
const catalogFile = path.basename(catalogChunk.file);
|
|
const catalogPath = path.join(dist, 'assets', catalogFile);
|
|
if (!fs.existsSync(catalogPath)) throw new Error(`The ready build is missing its ability catalog chunk: ${catalogFile}`);
|
|
const { parseAst } = await import('rollup/parseAst');
|
|
const catalogSource = fs.readFileSync(catalogPath, 'utf8');
|
|
const catalogAst = parseAst(catalogSource);
|
|
const declarations = catalogAst.body.filter((node) => node.type === 'FunctionDeclaration' && node.id);
|
|
const preloadFunctions = declarations.filter((node) => {
|
|
const body = catalogSource.slice(node.start, node.end);
|
|
return node.async && node.params.length === 1 && body.includes('new Set(')
|
|
&& body.includes('.has(') && body.includes('.push(') && body.includes('Promise.all(');
|
|
});
|
|
const resourceProfileFunctions = declarations.filter((node) => node.params.length === 1
|
|
&& catalogSource.slice(node.start, node.end).includes('Ability catalog for'));
|
|
if (preloadFunctions.length !== 1) {
|
|
throw new Error(`Expected one source-derived production ability catalog preloader in ${catalogFile}; found ${preloadFunctions.length}.`);
|
|
}
|
|
if (resourceProfileFunctions.length !== 1) {
|
|
throw new Error(`Expected one source-derived production resource-profile verifier in ${catalogFile}; found ${resourceProfileFunctions.length}.`);
|
|
}
|
|
const exportNode = [...catalogAst.body].reverse().find((node) => node.type === 'ExportNamedDeclaration' && node.specifiers?.length);
|
|
const aliasFor = (localName, label) => {
|
|
const specifiers = exportNode?.specifiers.filter((specifier) => specifier.local?.name === localName) ?? [];
|
|
if (specifiers.length !== 1) throw new Error(`The ready production bundle does not uniquely export its source-derived ${label}.`);
|
|
return specifiers[0].exported.name;
|
|
};
|
|
const catalogExports = {
|
|
preloader: aliasFor(preloadFunctions[0].id.name, 'catalog preloader'),
|
|
resourceProfileForClass: aliasFor(resourceProfileFunctions[0].id.name, 'resource profile verifier'),
|
|
};
|
|
const files = [...new Set([
|
|
...fs.readdirSync(path.join(dist, 'assets')).filter((file) => /^(?:index|GameRuntime|combatStore|partyStore|dungeonSession|dungeonRuns)-.*\.js$/.test(file)),
|
|
dungeonRunFile,
|
|
catalogFile,
|
|
])];
|
|
if (files.length < 2) throw new Error(`The ready build is missing game runtime chunks: ${files.join(', ')}`);
|
|
return { dist, files, urls: files.map((file) => `/assets/${file}`), catalogUrl: `/assets/${catalogFile}`, catalogExports };
|
|
}
|
|
|
|
function installStoreReader(page, modules) {
|
|
return page.addInitScript(({ moduleUrls, catalogUrl, catalogExports }) => {
|
|
window.coaGuardianStores = async () => {
|
|
const modules = await Promise.all(moduleUrls.map((url) => import(url)));
|
|
const catalog = await import(catalogUrl);
|
|
const values = modules.flatMap((module) => Object.values(module));
|
|
const stores = values.filter((value) => value && typeof value.getState === 'function');
|
|
return {
|
|
game: stores.find((store) => typeof store.getState()?.activateDungeon === 'function'
|
|
&& typeof store.getState()?.setAssetStatus === 'function'),
|
|
shell: stores.find((store) => typeof store.getState()?.saveActiveProgression === 'function'
|
|
&& typeof store.getState()?.saveActiveActionBindings === 'function'),
|
|
combat: stores.find((store) => typeof store.getState()?.castAbility === 'function'
|
|
&& typeof store.getState()?.synchronizeActors === 'function'),
|
|
party: stores.find((store) => typeof store.getState()?.setCommand === 'function'
|
|
&& Array.isArray(store.getState()?.members)),
|
|
dungeonRun: stores.find((store) => typeof store.getState()?.begin === 'function'
|
|
&& typeof store.getState()?.advance === 'function'),
|
|
preloadAbilityCatalogsForClasses: catalog[catalogExports.preloader],
|
|
resourceProfileForClass: catalog[catalogExports.resourceProfileForClass],
|
|
};
|
|
};
|
|
}, { moduleUrls: modules.urls, catalogUrl: modules.catalogUrl, catalogExports: modules.catalogExports });
|
|
}
|
|
|
|
async function readLiveState(page) {
|
|
return page.evaluate(async ({ formationIds }) => {
|
|
const { game, shell, combat, party, dungeonRun } = await window.coaGuardianStores();
|
|
if (!game || !shell || !combat || !party || !dungeonRun) throw new Error('Could not locate live game stores in the ready build.');
|
|
const g = game.getState();
|
|
const c = combat.getState();
|
|
const p = party.getState();
|
|
const abilityNamesById = new Map((c.abilities ?? []).map((ability) => [ability.id, ability.name]));
|
|
const run = dungeonRun.getState().progress;
|
|
const mobs = Object.values(c.mobs ?? {});
|
|
const activeAuras = (c.auras ?? []).filter((aura) => !Number.isFinite(aura.expiresAt) || aura.expiresAt > Date.now());
|
|
const playerAuras = activeAuras.filter((aura) => aura.sourceId === '__player__' && aura.targetId === '__player__');
|
|
const selectedBoss = run?.biteSizedSelection?.boss ?? null;
|
|
const selectedEntityId = selectedBoss?.entityId ?? null;
|
|
const normalizedServerEntry = typeof selectedEntityId === 'string'
|
|
? Number(selectedEntityId.match(/(?:entry-)?(\d+)$/)?.[1]) || null
|
|
: selectedEntityId;
|
|
const selectedBossMob = selectedBoss
|
|
? mobs.find((mob) => mob.id === selectedBoss.objectiveId
|
|
|| mob.serverEntry === selectedEntityId
|
|
|| (normalizedServerEntry !== null && String(mob.serverEntry) === String(normalizedServerEntry))
|
|
|| mob.spawnId === selectedBoss.objectiveId)
|
|
: null;
|
|
const namedBosses = mobs.filter((mob) => mob.boss).map((mob) => ({ id: mob.id, name: mob.name, health: mob.health,
|
|
maxHealth: mob.maxHealth, active: Boolean(mob.engaged), position: mob.position ?? null, homePosition: mob.homePosition ?? null }));
|
|
return {
|
|
observedAt: new Date().toISOString(),
|
|
game: { activeDungeonId: g.activeDungeonId, gameMode: g.gameMode, overlay: g.overlay,
|
|
assetStatus: g.assetStatus, position: g.playerPosition, areaName: g.areaName },
|
|
run: run ? { dungeonId: run.dungeonId, runId: run.id, startedAt: run.startedAt,
|
|
killedBossIds: run.killedBossIds, biteSizedSelection: run.biteSizedSelection ?? null } : null,
|
|
player: { name: shell.getState().activeCharacter?.name ?? null, classId: c.classId,
|
|
specializationId: c.specializationId ?? null, level: c.level, role: c.gearRole,
|
|
health: c.health, maxHealth: c.maxHealth, resource: c.resource, maxResource: c.maxResource,
|
|
gearStats: c.gearStats ?? null, talentRanks: c.talentRanks,
|
|
actionBindings: c.actionBindings,
|
|
abilities: (c.abilities ?? []).filter((ability) => /^(?:Tower Formation|Line Formation|Heavy Blow|Raise Shield|Smash|Paragon Smash)$/i.test(ability.name))
|
|
.map((ability) => ({ id: ability.id, name: ability.name, dbcSpellId: ability.dbcSpellId ?? null,
|
|
unlockLevel: ability.unlockLevel, learned: !ability.passive && ability.unlockLevel <= c.level,
|
|
talentEntryId: ability.talentEntryId ?? null })),
|
|
auras: playerAuras.map((aura) => ({ id: aura.definition?.id ?? null, name: aura.definition?.name ?? null,
|
|
sourceId: aura.sourceId, targetId: aura.targetId, stacks: aura.stacks ?? null,
|
|
appliedAt: aura.appliedAt ?? null, expiresAt: aura.expiresAt ?? null })),
|
|
formationAuraIds: playerAuras.filter((aura) => [formationIds.tower, formationIds.line,
|
|
formationIds.paragonReady, formationIds.paragonSmash].includes(aura.definition?.id)).map((aura) => aura.definition.id),
|
|
selectedTargetId: c.selectedTargetId ?? null,
|
|
casts: (c.combatEvents ?? []).filter((event) => event.kind === 'cast' && event.sourceActorId === '__player__')
|
|
.slice(-80).map((event) => ({ at: event.at, abilityId: event.abilityId,
|
|
name: event.abilityName ?? abilityNamesById.get(event.abilityId) ?? null,
|
|
targetActorId: event.targetActorId, result: event.result ?? null })),
|
|
},
|
|
party: p.members.map((member) => ({ id: member.id, name: member.name, classId: member.classId,
|
|
specialization: member.specialization, specializationId: member.specializationId, role: member.role,
|
|
level: member.level, statRuleset: member.statRuleset, health: member.health, maxHealth: member.maxHealth,
|
|
talentRanks: member.talentRanks, position: window.coaGuardianReadPartyPosition?.(member.id) ?? null })),
|
|
bossSelection: selectedBoss ? { selected: selectedBoss,
|
|
observedName: selectedBossMob?.name ?? namedBosses.find((mob) => mob.id === selectedBoss.objectiveId)?.name ?? null,
|
|
observedMobId: selectedBossMob?.id ?? null } : null,
|
|
bossMobs: namedBosses,
|
|
bossIntro: document.querySelector('.dungeon-boss-intro')?.innerText ?? null,
|
|
};
|
|
}, { formationIds: FORMATION_IDS });
|
|
}
|
|
|
|
async function preloadExactCoaPartyCatalogs(page, catalogExports) {
|
|
const catalogPreparation = await page.evaluate(async (resolvedPublicExports) => {
|
|
const { preloadAbilityCatalogsForClasses, resourceProfileForClass } = await window.coaGuardianStores();
|
|
if (typeof preloadAbilityCatalogsForClasses !== 'function' || typeof resourceProfileForClass !== 'function') {
|
|
throw new Error('The AST-derived production catalog API aliases did not resolve to callable exports.');
|
|
}
|
|
const classIds = ['guardian', 'witch-doctor', 'barbarian', 'ranger', 'pyromancer'];
|
|
await preloadAbilityCatalogsForClasses(classIds);
|
|
const verified = classIds.map((classId) => {
|
|
const resourceProfile = resourceProfileForClass(classId);
|
|
if (!resourceProfile || typeof resourceProfile !== 'object') {
|
|
throw new Error(`Production resource profile for ${classId} did not load.`);
|
|
}
|
|
return { classId, resourceProfileKeys: Object.keys(resourceProfile).sort() };
|
|
});
|
|
return {
|
|
seam: 'isolated pre-entry call to the source-derived public production preloadAbilityCatalogsForClasses export, followed by public resourceProfileForClass checks; aliases were resolved from the owning Vite chunk export AST; no module reset or character-switch warmup',
|
|
sourceDerivedUniquePreloader: true,
|
|
sourceDerivedUniqueResourceVerifier: true,
|
|
resolvedPublicExports,
|
|
classIds,
|
|
verified,
|
|
completedAt: new Date().toISOString(),
|
|
};
|
|
}, catalogExports);
|
|
return catalogPreparation;
|
|
}
|
|
|
|
async function prepareCharacterAndBindings(page, prepared, capture, catalogExports) {
|
|
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: 'CoA' }).click();
|
|
await page.locator('.class-option').filter({ hasText: /^Guardian/ }).click();
|
|
await page.locator('.creator-specializations button').filter({ hasText: 'Vanguard' }).click();
|
|
await page.getByRole('button', { name: /Customize & name/ }).click();
|
|
await page.getByLabel('Character name').fill('Formguard');
|
|
await page.getByRole('button', { name: /Create character/ }).click();
|
|
await page.locator('.character-select-screen').waitFor();
|
|
await page.locator('.roster-character.is-active').filter({ hasText: 'Formguard' }).waitFor();
|
|
await page.locator('.roster-character-info h1').getByText('Formguard', { exact: true }).waitFor();
|
|
await page.locator('.roster-stage .character-preview__viewport').waitFor({ state: 'visible' });
|
|
await page.waitForTimeout(500);
|
|
await capture('00-character-select-guardian-roster-avatar');
|
|
await page.getByRole('button', { name: /^Continue as Formguard/ }).click();
|
|
await page.locator('.main-menu-screen').waitFor({ timeout: 120_000 });
|
|
const catalogPreparation = await preloadExactCoaPartyCatalogs(page, catalogExports);
|
|
|
|
const profile = await page.evaluate(async ({ selectedBuild, selectedRanks }) => {
|
|
const { shell } = await window.coaGuardianStores();
|
|
const state = shell.getState();
|
|
const character = state.characters.find((entry) => entry.id === state.selectedCharacterId);
|
|
if (!character || character.classId !== 'guardian' || character.specializationId !== 'guardian:vanguard') {
|
|
throw new Error('Expected a UI-created Guardian/Vanguard character profile.');
|
|
}
|
|
shell.setState({ activeCharacter: character });
|
|
shell.getState().saveActiveProgression(60, 0, selectedRanks);
|
|
const updated = shell.getState().characters.find((entry) => entry.id === character.id);
|
|
if (!updated || updated.level !== 60 || JSON.stringify(updated.talentRanks) !== JSON.stringify(selectedRanks)) {
|
|
throw new Error('Pre-entry level-60 Guardian talent allocation did not persist.');
|
|
}
|
|
shell.setState({ activeCharacter: updated });
|
|
return { id: updated.id, name: updated.name, classId: updated.classId,
|
|
specializationId: updated.specializationId, level: updated.level,
|
|
sourceBuildId: selectedBuild.canonicalBuildId, talentAllocation: selectedBuild.kind,
|
|
talentRanks: selectedRanks };
|
|
}, { selectedBuild: prepared.playerTalentAllocation, selectedRanks: prepared.playerTalentAllocation.selectedTalentRanks });
|
|
const bindings = await bindActions(page, prepared);
|
|
profile.bindings = bindings;
|
|
profile.bindingsWereConfiguredBeforeEntry = true;
|
|
profile.catalogPreparation = catalogPreparation;
|
|
return profile;
|
|
}
|
|
|
|
async function installPartyInitializationFixture(page, prepared, catalogPreparation) {
|
|
return page.evaluate(async ({ fixture, dungeonId, catalogPreparation }) => {
|
|
const { game, party } = await window.coaGuardianStores();
|
|
if (!game || !party) throw new Error('Live party/game stores are not available.');
|
|
const configured = fixture.companions;
|
|
const expected = ['witch-doctor', 'barbarian', 'ranger', 'pyromancer'];
|
|
if (JSON.stringify(configured.map((member) => member.classId)) !== JSON.stringify(expected)) {
|
|
throw new Error('Refusing to replace the live party with a non-canonical CoA companion set.');
|
|
}
|
|
if (configured.some((member) => member.level !== 60)) {
|
|
throw new Error('The exact CoA companion fixture must remain at the prepared level 60.');
|
|
}
|
|
const expectedCatalogs = ['guardian', ...expected];
|
|
if (!catalogPreparation?.sourceDerivedUniquePreloader
|
|
|| !catalogPreparation?.sourceDerivedUniqueResourceVerifier
|
|
|| JSON.stringify(catalogPreparation.classIds) !== JSON.stringify(expectedCatalogs)
|
|
|| JSON.stringify(catalogPreparation.verified.map((entry) => entry.classId)) !== JSON.stringify(expectedCatalogs)) {
|
|
throw new Error('All five exact CoA party catalogs must pass the isolated pre-entry production preload and resource-profile verification.');
|
|
}
|
|
const originalInitializeParty = party.getState().initializeParty;
|
|
const originalResetCombat = party.getState().resetCombat;
|
|
const status = {
|
|
scope: 'isolated browser fixture; exact Bite-Sized Cellblock Rush + CoA Guardian tank initialization only',
|
|
seam: 'the unique source-derived production preloadAbilityCatalogsForClasses export was called once for Guardian, Witch Doctor, Barbarian, Ranger, and Pyromancer before entry; resourceProfileForClass was checked for all five; wrap PartyBridge initializeParty, call the original normal initializer, then substitute the already prepared canonical companions before PartyBridge synchronizes actors; the one-shot initializer wrapper restores itself, while resetCombat calls its original and restores only the fixture roster on retry',
|
|
installedAt: new Date().toISOString(),
|
|
catalogPreparation,
|
|
initializedByPartyBridge: false,
|
|
initialization: null,
|
|
lastReset: null,
|
|
initializationHookRestoredAt: null,
|
|
resetCount: 0,
|
|
uninstalledAt: null,
|
|
};
|
|
const installPreparedRoster = (args, source) => {
|
|
const state = party.getState();
|
|
const application = {
|
|
source,
|
|
seed: source === 'initializeParty' ? args[0] ?? state.seed : state.seed,
|
|
level: source === 'initializeParty' ? args[1] ?? null : args[0] ?? null,
|
|
playerHealth: source === 'resetCombat' ? args[1] ?? null : undefined,
|
|
playerRole: source === 'initializeParty' ? args[3] ?? state.playerRole : state.playerRole,
|
|
partySize: source === 'initializeParty' ? args[4] ?? state.partySize : state.partySize,
|
|
categoryId: source === 'initializeParty' ? args[5] ?? state.categoryId : state.categoryId,
|
|
generatedClassIdsBeforeFixture: state.members.map((member) => member.classId),
|
|
appliedClassIds: configured.map((member) => member.classId),
|
|
appliedAt: new Date().toISOString(),
|
|
};
|
|
if (source === 'initializeParty') {
|
|
status.initializedByPartyBridge = true;
|
|
status.initialization = application;
|
|
} else status.lastReset = application;
|
|
party.setState({ members: configured });
|
|
};
|
|
const appliesToThisRun = (args) => {
|
|
const currentGame = game.getState();
|
|
return currentGame.activeDungeonId === dungeonId
|
|
&& currentGame.gameMode === 'dungeon'
|
|
&& args[3] === 'tank' && args[4] === 5 && args[5] === 'coa';
|
|
};
|
|
const initializeWrapper = (...args) => {
|
|
const result = originalInitializeParty(...args);
|
|
if (appliesToThisRun(args)) {
|
|
installPreparedRoster(args, 'initializeParty');
|
|
// Keep subsequent entry initialization on the production function. The
|
|
// scoped reset wrapper below preserves this diagnostic roster on retry.
|
|
party.setState({ initializeParty: originalInitializeParty });
|
|
status.initializationHookRestoredAt = new Date().toISOString();
|
|
}
|
|
return result;
|
|
};
|
|
const resetWrapper = (...args) => {
|
|
const result = originalResetCombat(...args);
|
|
const currentGame = game.getState();
|
|
if (status.initializedByPartyBridge
|
|
&& currentGame.activeDungeonId === dungeonId && currentGame.gameMode === 'dungeon') {
|
|
status.resetCount += 1;
|
|
installPreparedRoster(args, 'resetCombat');
|
|
}
|
|
return result;
|
|
};
|
|
party.setState({ initializeParty: initializeWrapper, resetCombat: resetWrapper });
|
|
window.__coaGuardianPartyFixtureStatus = status;
|
|
window.__coaGuardianUninstallPartyFixture = () => {
|
|
const state = party.getState();
|
|
if (state.initializeParty === initializeWrapper) party.setState({ initializeParty: originalInitializeParty });
|
|
if (state.resetCombat === resetWrapper) party.setState({ resetCombat: originalResetCombat });
|
|
status.uninstalledAt = new Date().toISOString();
|
|
return status;
|
|
};
|
|
return status;
|
|
}, { fixture: prepared, dungeonId: DEFAULT_TEST_DUNGEON_ID, catalogPreparation });
|
|
}
|
|
|
|
async function readPartyFixtureStatus(page) {
|
|
return page.evaluate(() => window.__coaGuardianPartyFixtureStatus ?? null);
|
|
}
|
|
|
|
async function bindActions(page, prepared) {
|
|
await page.getByRole('button', { name: /Button assignments/ }).click();
|
|
const panel = page.locator('.ability-bindings-panel');
|
|
await panel.waitFor();
|
|
const assign = async (control, abilityName) => {
|
|
const slot = panel.locator(`.ability-bindings__diagram .ability-binding-slot--${control}`);
|
|
await slot.waitFor({ state: 'visible' });
|
|
await slot.click();
|
|
const choice = panel.locator('.ability-bindings__choices button').filter({ hasText: new RegExp(`^${abilityName}`, 'i') }).first();
|
|
await choice.waitFor({ state: 'visible' });
|
|
await choice.click();
|
|
};
|
|
await assign('face-bottom', 'Tower Formation');
|
|
await assign('face-right', 'Line Formation');
|
|
await assign('r1', 'Raise Shield');
|
|
const hasParagon = prepared.playerTalentAllocation.requiredNodes.paragon > 0
|
|
&& prepared.playerTalentAllocation.requiredNodes.heavyBlow > 0;
|
|
if (hasParagon) {
|
|
const slot = panel.locator('.ability-bindings__diagram .ability-binding-slot--r2');
|
|
await slot.click();
|
|
const pickerOrder = await page.evaluate(async () => {
|
|
const { combat } = await window.coaGuardianStores();
|
|
const state = combat.getState();
|
|
const ordered = state.abilities.filter((ability) => !ability.passive)
|
|
.sort((left, right) => left.unlockLevel - right.unlockLevel).map((ability) => ability.id);
|
|
const current = state.actionBindings['primary:r2'];
|
|
return current ? [current, ...ordered.filter((id) => id !== current)] : ordered;
|
|
});
|
|
const index = pickerOrder.indexOf('coa-tree-31169');
|
|
if (index < 0) throw new Error('The legal Paragon fixture does not expose its Heavy Blow action in Button Assignments.');
|
|
const heavyBlowChoice = panel.locator('.ability-bindings__choices button').nth(index);
|
|
assert.match(await heavyBlowChoice.innerText(), /Heavy Blow/i);
|
|
await heavyBlowChoice.click();
|
|
}
|
|
await panel.getByRole('button', { name: 'Close button assignments' }).click();
|
|
await page.locator('.main-menu-screen').waitFor();
|
|
return page.evaluate(async () => {
|
|
const { combat } = await window.coaGuardianStores();
|
|
return combat.getState().actionBindings;
|
|
});
|
|
}
|
|
|
|
async function castVisibleAbility(page, abilityName, timeout = 12_000) {
|
|
const button = page.locator(`.action-bar button.action-slot:visible[aria-label^="${abilityName},"][aria-disabled="false"]`).first();
|
|
try {
|
|
await button.waitFor({ state: 'visible', timeout });
|
|
} catch (error) {
|
|
const observedSlots = await page.locator('.action-bar button.action-slot:visible').evaluateAll((slots) => slots.map((slot) => ({
|
|
label: slot.getAttribute('aria-label'), disabled: slot.getAttribute('aria-disabled'), text: slot.textContent?.trim() ?? '',
|
|
})));
|
|
throw new Error(`No visible enabled ${abilityName} action slot after ${timeout}ms. Observed: ${JSON.stringify(observedSlots)}. ${error.message}`);
|
|
}
|
|
await button.click();
|
|
}
|
|
|
|
async function runLive(prepared, preparationOutput) {
|
|
const modules = await productionModules();
|
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
|
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-coa-guardian-live-'));
|
|
const { createGameServer } = await import('../server/server.mjs');
|
|
const runtime = createGameServer({ dataDir: dataDirectory, staticDir: modules.dist,
|
|
contentDir: path.join(projectRoot, 'content'), 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();
|
|
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(30_000);
|
|
const pageErrors = [];
|
|
const consoleErrors = [];
|
|
const httpErrors = [];
|
|
const failedRequests = [];
|
|
const keyboardEvents = [];
|
|
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()); });
|
|
page.on('response', (response) => {
|
|
if (response.status() >= 400) httpErrors.push({ status: response.status(), url: response.url() });
|
|
});
|
|
page.on('requestfailed', (request) => failedRequests.push({ url: request.url(), failure: request.failure()?.errorText ?? null }));
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'debug' && message.text().startsWith('coa-live-key:')) keyboardEvents.push(message.text());
|
|
});
|
|
await installStoreReader(page, modules);
|
|
const result = {
|
|
schemaVersion: 1,
|
|
status: 'running',
|
|
scope: 'bounded live Guardian/Vanguard formation diagnostic in Bite-Sized Cellblock Rush',
|
|
enteredThrough: 'normal UI character creation, Dungeons picker, Bite-Sized category, and Tank role selection; party roster fixture is applied inside the isolated browser during the normal PartyBridge initializeParty call',
|
|
coverageLimitSeconds: routeSeconds,
|
|
buildDirectory: path.relative(projectRoot, modules.dist),
|
|
buildAssets: modules.files,
|
|
preparation: { output: preparationOutput, fixture: prepared },
|
|
evidence: { screenshots: [], pageErrors, consoleErrors, httpErrors, failedRequests },
|
|
naturalCombat: { playerInputs: [], events: [], bossSelection: null, limitations: [] },
|
|
};
|
|
const capture = async (name) => {
|
|
const target = path.join(outputDirectory, `${name}.png`);
|
|
await page.screenshot({ path: target, fullPage: false });
|
|
result.evidence.screenshots.push(target);
|
|
return target;
|
|
};
|
|
const awaitWithoutPageException = async (promise, phase) => {
|
|
if (pageErrors.length) throw new Error(`Browser page exception before ${phase}: ${pageErrors.at(-1)}`);
|
|
let handler;
|
|
const pageFailure = new Promise((resolve, reject) => {
|
|
handler = (error) => reject(new Error(`Browser page exception during ${phase}: ${error.message}`));
|
|
page.once('pageerror', handler);
|
|
});
|
|
try {
|
|
return await Promise.race([promise, pageFailure]);
|
|
} finally {
|
|
if (handler) page.off('pageerror', handler);
|
|
}
|
|
};
|
|
try {
|
|
await page.goto(`http://127.0.0.1:${address.port}`, { waitUntil: 'domcontentloaded' });
|
|
const profile = await prepareCharacterAndBindings(page, prepared, capture, modules.catalogExports);
|
|
result.playerProfile = profile;
|
|
await page.getByRole('button', { name: /^Dungeons/ }).click();
|
|
await page.locator('.dungeon-select-screen').waitFor();
|
|
await selectDefaultTestDungeon(page);
|
|
await page.locator('.dungeon-select-copy h2').getByText(DEFAULT_TEST_DUNGEON_TITLE, { exact: true }).waitFor();
|
|
await capture('01-cellblock-picker');
|
|
await awaitWithoutPageException(openSelectedDungeonRoles(page), 'Cellblock Rush role selection');
|
|
// Preload class resources and install a one-shot, isolated lifecycle hook.
|
|
// The real PartyBridge initializer will create the in-dungeon party before
|
|
// the prepared CoA companions replace those generated members.
|
|
result.partyFixture = await installPartyInitializationFixture(page, prepared, profile.catalogPreparation);
|
|
await page.locator('.dungeon-role-option--tank').waitFor({ state: 'visible', timeout: 180_000 });
|
|
await page.locator('.dungeon-role-option--tank').click();
|
|
await awaitWithoutPageException(page.locator('.party-frames').waitFor({ timeout: 60_000 }), 'party initialization');
|
|
await page.waitForFunction(async (expectedId) => {
|
|
const { game } = await window.coaGuardianStores();
|
|
return game.getState().activeDungeonId === expectedId && game.getState().assetStatus === 'ready';
|
|
}, DEFAULT_TEST_DUNGEON_ID, { timeout: 240_000 });
|
|
await page.waitForFunction(() => window.__coaGuardianPartyFixtureStatus?.initializedByPartyBridge === true,
|
|
null, { timeout: 30_000 });
|
|
|
|
const bossIntro = page.locator('.dungeon-boss-intro');
|
|
let bossIntroVisible = await bossIntro.isVisible().catch(() => false);
|
|
if (!bossIntroVisible) {
|
|
try {
|
|
await awaitWithoutPageException(bossIntro.waitFor({ state: 'visible', timeout: 8_000 }), 'Cellblock Rush boss intro');
|
|
bossIntroVisible = true;
|
|
} catch (error) {
|
|
if (pageErrors.length) throw error;
|
|
result.bossIntroEvidence = {
|
|
visibleObserved: false,
|
|
screenshot: null,
|
|
note: 'The entry countdown was already gone or not visible at the bounded intro check; the following body screenshot was taken after the dungeon scene was ready.',
|
|
};
|
|
}
|
|
}
|
|
if (bossIntroVisible) {
|
|
result.bossIntroEvidence = {
|
|
visibleObserved: true,
|
|
capturedAt: new Date().toISOString(),
|
|
text: await bossIntro.innerText(),
|
|
screenshot: await capture('02-live-cellblock-boss-intro-countdown'),
|
|
note: 'This image intentionally records the selected boss during the normal entry countdown; body silhouette is captured after the intro closes.',
|
|
};
|
|
await bossIntro.waitFor({ state: 'detached', timeout: 30_000 });
|
|
}
|
|
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 180_000 }).catch(() => {});
|
|
await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur());
|
|
await page.waitForTimeout(1_000);
|
|
let state = await readLiveState(page);
|
|
assert.equal(state.game.activeDungeonId, DEFAULT_TEST_DUNGEON_ID);
|
|
assert.equal(state.game.gameMode, 'dungeon');
|
|
assert.equal(state.player.classId, 'guardian');
|
|
assert.equal(state.player.specializationId, 'guardian:vanguard');
|
|
assert.equal(state.player.level, 60);
|
|
assert.equal(state.player.actionBindings['primary:r2'], 'coa-tree-31169',
|
|
'The pre-entry Heavy Blow assignment must survive normal dungeon entry.');
|
|
const initialFormationAuras = state.player.formationAuraIds.filter((id) => (
|
|
id === FORMATION_IDS.tower || id === FORMATION_IDS.line
|
|
));
|
|
assert.deepEqual(initialFormationAuras, [], 'Tower and Line must have no pre-cast aura markers at entry.');
|
|
assert.equal(state.party.length, 4);
|
|
assert.deepEqual(state.party.map((member) => member.classId), ['witch-doctor', 'barbarian', 'ranger', 'pyromancer']);
|
|
result.partyAtEntry = state.party;
|
|
result.preCastFormationState = { actionBinding: state.player.actionBindings['primary:r2'], initialFormationAuraIds: initialFormationAuras };
|
|
result.partyFixture.status = await readPartyFixtureStatus(page);
|
|
assert.equal(result.partyFixture.status.initialization.source, 'initializeParty');
|
|
assert.equal(result.partyFixture.status.initialization.categoryId, 'coa');
|
|
assert.equal(result.partyFixture.status.initialization.playerRole, 'tank');
|
|
assert.equal(result.partyFixture.status.initialization.partySize, 5);
|
|
if (!state.run?.biteSizedSelection?.boss) {
|
|
throw new Error('Cellblock Rush run did not expose its seeded boss selection; refusing to infer a boss.');
|
|
}
|
|
result.naturalCombat.bossSelection = state.bossSelection;
|
|
result.entryState = state;
|
|
result.entryBodyScreenshot = await capture('03-live-entry-player-body-and-equipped-silhouette');
|
|
|
|
await castVisibleAbility(page, 'Tower Formation');
|
|
await page.waitForFunction(async (id) => {
|
|
const { combat } = await window.coaGuardianStores();
|
|
return combat.getState().auras.some((aura) => aura.definition?.id === id
|
|
&& aura.sourceId === '__player__' && aura.targetId === '__player__'
|
|
&& (!Number.isFinite(aura.expiresAt) || aura.expiresAt > Date.now()));
|
|
}, FORMATION_IDS.tower, { timeout: 12_000 });
|
|
state = await readLiveState(page);
|
|
assert(state.player.formationAuraIds.includes(FORMATION_IDS.tower), 'Tower Formation is the player-owned live aura.');
|
|
result.formationEvidence = [{ formation: 'Tower Formation', auraId: FORMATION_IDS.tower, state }];
|
|
await capture('04-player-tower-formation-aura');
|
|
|
|
await castVisibleAbility(page, 'Line Formation');
|
|
await page.waitForFunction(async (id) => {
|
|
const { combat } = await window.coaGuardianStores();
|
|
return combat.getState().auras.some((aura) => aura.definition?.id === id
|
|
&& aura.sourceId === '__player__' && aura.targetId === '__player__'
|
|
&& (!Number.isFinite(aura.expiresAt) || aura.expiresAt > Date.now()));
|
|
}, FORMATION_IDS.line, { timeout: 12_000 });
|
|
state = await readLiveState(page);
|
|
assert(state.player.formationAuraIds.includes(FORMATION_IDS.line), 'Line Formation is the player-owned live aura.');
|
|
result.formationEvidence.push({ formation: 'Line Formation', auraId: FORMATION_IDS.line, state });
|
|
await capture('05-player-line-formation-aura');
|
|
await castVisibleAbility(page, 'Tower Formation');
|
|
await page.waitForFunction(async (id) => {
|
|
const { combat } = await window.coaGuardianStores();
|
|
return combat.getState().auras.some((aura) => aura.definition?.id === id
|
|
&& aura.sourceId === '__player__' && aura.targetId === '__player__'
|
|
&& (!Number.isFinite(aura.expiresAt) || aura.expiresAt > Date.now()));
|
|
}, FORMATION_IDS.tower, { timeout: 12_000 });
|
|
state = await readLiveState(page);
|
|
result.formationEvidence.push({ formation: 'Tower Formation (combat window)', auraId: FORMATION_IDS.tower, state });
|
|
await capture('06-player-tower-formation-combat-window');
|
|
result.playerProfile.bindingsWereConfiguredBeforeEntry = true;
|
|
|
|
const liveUntil = Date.now() + routeSeconds * 1_000;
|
|
let nextTargetAt = 0;
|
|
let nextCastAt = 0;
|
|
let nextSnapshotAt = 0;
|
|
let movementPulse = 0;
|
|
let runningKey = false;
|
|
let runningKeyStartedAt = 0;
|
|
while (Date.now() < liveUntil) {
|
|
const now = Date.now();
|
|
if (runningKey) {
|
|
await page.keyboard.up('w');
|
|
const releasedAt = Date.now();
|
|
result.naturalCombat.playerInputs.push({ at: new Date(runningKeyStartedAt).toISOString(),
|
|
kind: 'movement', input: 'W', releasedAt: new Date(releasedAt).toISOString(), durationMs: releasedAt - runningKeyStartedAt });
|
|
runningKey = false;
|
|
}
|
|
if (now >= nextTargetAt) {
|
|
await page.keyboard.press('Tab').catch(() => {});
|
|
result.naturalCombat.playerInputs.push({ at: new Date().toISOString(), kind: 'target-cycle', input: 'Tab' });
|
|
nextTargetAt = now + 2_500;
|
|
}
|
|
if (now >= nextCastAt) {
|
|
const mechanics = prepared.playerTalentAllocation.requiredNodes;
|
|
const candidates = mechanics.paragon > 0 && mechanics.heavyBlow > 0
|
|
? ['Paragon Smash', 'Heavy Blow'] : ['Raise Shield'];
|
|
let selectedAction = null;
|
|
for (const candidate of candidates) {
|
|
const action = page.locator(`.action-bar button.action-slot:visible[aria-label^="${candidate},"][aria-disabled="false"]`).first();
|
|
if (await action.count()) {
|
|
selectedAction = { action, ability: candidate };
|
|
break;
|
|
}
|
|
}
|
|
if (selectedAction) {
|
|
try {
|
|
await selectedAction.action.click();
|
|
result.naturalCombat.playerInputs.push({ at: new Date().toISOString(), kind: 'action-bar-click', ability: selectedAction.ability });
|
|
} catch (error) {
|
|
result.naturalCombat.limitations.push(`The ordinary ${selectedAction.ability} action-bar click failed: ${error.message}`);
|
|
}
|
|
}
|
|
nextCastAt = now + 5_500;
|
|
}
|
|
if (now >= nextSnapshotAt) {
|
|
state = await readLiveState(page);
|
|
result.naturalCombat.events.push({ at: state.observedAt, playerHealth: state.player.health,
|
|
playerMaxHealth: state.player.maxHealth, target: state.player.selectedTargetId,
|
|
activeBosses: state.bossMobs.filter((mob) => mob.active), bossSelection: state.bossSelection,
|
|
auras: state.player.auras, casts: state.player.casts.slice(-12),
|
|
paragonState: state.player.auras.filter((aura) => [FORMATION_IDS.paragonReady, FORMATION_IDS.paragonSmash].includes(aura.id)) });
|
|
if (state.bossSelection?.observedName && !result.naturalCombat.bossSelection?.observedName) {
|
|
result.naturalCombat.bossSelection = state.bossSelection;
|
|
}
|
|
nextSnapshotAt = now + 1_500;
|
|
}
|
|
if (movementPulse % 4 === 0) {
|
|
await page.keyboard.down('w');
|
|
runningKey = true;
|
|
runningKeyStartedAt = Date.now();
|
|
}
|
|
movementPulse += 1;
|
|
await page.waitForTimeout(100);
|
|
if (await page.locator('.dungeon-completion-screen, .dungeon-results-screen').count()) break;
|
|
}
|
|
if (runningKey) {
|
|
await page.keyboard.up('w').catch(() => {});
|
|
const releasedAt = Date.now();
|
|
result.naturalCombat.playerInputs.push({ at: new Date(runningKeyStartedAt).toISOString(),
|
|
kind: 'movement', input: 'W', releasedAt: new Date(releasedAt).toISOString(), durationMs: releasedAt - runningKeyStartedAt });
|
|
}
|
|
state = await readLiveState(page);
|
|
result.endpointState = state;
|
|
result.naturalCombat.bossSelection = state.bossSelection ?? result.naturalCombat.bossSelection;
|
|
if (!result.naturalCombat.bossSelection?.selected) {
|
|
throw new Error('The live run ended without a recordable seeded Cellblock boss selection.');
|
|
}
|
|
if (!result.naturalCombat.bossSelection.observedName) {
|
|
result.naturalCombat.limitations.push('The run selection identified the selected boss, but its live actor was not observed during the bounded route window.');
|
|
}
|
|
const paragonExpected = prepared.playerTalentAllocation.requiredNodes.paragon > 0
|
|
&& prepared.playerTalentAllocation.requiredNodes.heavyBlow > 0;
|
|
const paragonSamples = result.naturalCombat.events.flatMap((entry) => entry.paragonState ?? []);
|
|
result.naturalCombat.paragon = {
|
|
selectedByPreEntryBuild: paragonExpected,
|
|
readyAuraObserved: paragonSamples.some((aura) => aura.id === FORMATION_IDS.paragonReady),
|
|
smashAuraObserved: paragonSamples.some((aura) => aura.id === FORMATION_IDS.paragonSmash),
|
|
stackEvidence: paragonSamples,
|
|
claimLimit: 'No stack state was injected. An absent aura means natural block/cast pressure did not establish a live proc in this bounded pass.',
|
|
};
|
|
if (!paragonExpected) result.naturalCombat.limitations.push('The legal level-60 first-party build did not learn both Paragon and the Heavy Blow talent node; Paragon Smash was not tested.');
|
|
result.naturalCombat.naturalCompanionTowerPreparation = {
|
|
status: 'not-applicable',
|
|
reason: 'The exact five-seat first-party roster has Guardian as the active player and contains no Guardian companion.',
|
|
};
|
|
await capture('07-cellblock-bounded-live-endpoint');
|
|
assert.deepEqual(pageErrors, [], `No browser page exceptions: ${JSON.stringify(pageErrors)}`);
|
|
result.status = 'complete-diagnostic-no-clear-claim';
|
|
result.evidence.pageErrors = pageErrors;
|
|
result.evidence.consoleErrors = consoleErrors;
|
|
result.evidence.httpErrors = httpErrors;
|
|
result.evidence.failedRequests = failedRequests;
|
|
result.keyboardEvents = keyboardEvents;
|
|
result.completedAt = new Date().toISOString();
|
|
writeJson(resultPath, result);
|
|
console.log(`Guardian/Vanguard Cellblock Rush diagnostic complete: ${outputDirectory}`);
|
|
} catch (error) {
|
|
result.status = 'failed';
|
|
result.error = error.stack ?? error.message;
|
|
result.evidence.pageErrors = pageErrors;
|
|
result.evidence.consoleErrors = consoleErrors;
|
|
result.evidence.httpErrors = httpErrors;
|
|
result.evidence.failedRequests = failedRequests;
|
|
result.completedAt = new Date().toISOString();
|
|
if (page) await page.screenshot({ path: path.join(outputDirectory, 'failure.png') }).catch(() => {});
|
|
writeJson(resultPath, result);
|
|
throw error;
|
|
} finally {
|
|
await page.evaluate(() => window.__coaGuardianUninstallPartyFixture?.()).catch(() => {});
|
|
await context.close().catch(() => {});
|
|
await browser.close().catch(() => {});
|
|
await new Promise((resolve) => runtime.server.close(resolve));
|
|
const actual = fs.realpathSync(dataDirectory);
|
|
if (path.dirname(actual) !== fs.realpathSync(os.tmpdir()) || !path.basename(actual).startsWith('healer-man-coa-guardian-live-')) {
|
|
throw new Error('Unsafe temporary CoA playtest database cleanup path.');
|
|
}
|
|
fs.rmSync(actual, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
if (liveRequested === prepareOnly) {
|
|
throw new Error('Choose exactly one mode: --prepare-only or --run-live. Live mode must be started only after the final build is ready.');
|
|
}
|
|
const preparedResult = prepareFirstPartyParty();
|
|
const { prepared, output } = preparedResult;
|
|
if (prepareOnly) {
|
|
const summary = { status: 'prepared-static-only', outputDirectory,
|
|
preparation: output, requiredNodes: prepared.playerTalentAllocation.requiredNodes,
|
|
allocationKind: prepared.playerTalentAllocation.kind,
|
|
partyBuildIds: prepared.partyBuildIds,
|
|
note: 'No server, build, browser, or dungeon was started.' };
|
|
writeJson(path.join(outputDirectory, 'preparation-summary.json'), summary);
|
|
console.log(JSON.stringify(summary, null, 2));
|
|
return;
|
|
}
|
|
await runLive(prepared, output);
|
|
})().catch((error) => {
|
|
console.error(error.stack ?? error.message);
|
|
process.exitCode = 1;
|
|
});
|