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; });