const { DEFAULT_TEST_DUNGEON_TITLE, selectDefaultTestDungeon, openSelectedDungeonRoles } = require('./playtest-defaults.cjs'); 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 directory = path.resolve(process.env.HUNTER_FAMILY_PLAYTEST_DIR || 'playtest-artifacts/hunter-families'); const staticDir = path.resolve('dist'); const contentDir = path.resolve('content'); const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-hunter-families-')); function cleanupDataDir() { const resolvedDataDir = path.resolve(dataDir); const resolvedTempDir = path.resolve(os.tmpdir()); if (path.dirname(resolvedDataDir) !== resolvedTempDir || !path.basename(resolvedDataDir).startsWith('healer-man-hunter-families-')) { throw new Error(`Refusing recursive cleanup outside the dedicated Hunter QA temp directory: ${resolvedDataDir}`); } fs.rmSync(resolvedDataDir, { recursive: true, force: true }); } const assetDirectory = path.join(staticDir, 'assets'); if (!fs.existsSync(path.join(staticDir, 'index.html'))) { cleanupDataDir(); throw new Error('A fresh production build is required before this playtest. Build dist, then rerun.'); } const assets = fs.readdirSync(assetDirectory); const mainModule = `/assets/${assets.find((file) => /^index-.*\.js$/.test(file))}`; const combatModule = `/assets/${assets.find((file) => /^combatStore-.*\.js$/.test(file))}`; if (mainModule.endsWith('undefined') || combatModule.endsWith('undefined')) { cleanupDataDir(); throw new Error('Could not locate the production app and combat store modules in dist/assets.'); } fs.mkdirSync(directory, { recursive: true }); async function storeSnapshot(page) { return page.evaluate(async () => { const { shell, combat, game } = await window.hunterFamilyStores(); const roster = shell.getState(); const runtime = combat.getState(); const activePet = runtime.summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__'); const activeProfile = roster.characters.find((character) => character.id === roster.selectedCharacterId); return { phase: roster.phase, selectedCharacter: activeProfile ? { name: activeProfile.name, classId: activeProfile.classId, level: activeProfile.level, specializationId: activeProfile.specializationId, hunterPetFamily: activeProfile.hunterPetFamily ?? null, } : null, combat: { classId: runtime.classId, level: runtime.level, family: runtime.hunterPetFamily, activePet: activePet ? { name: activePet.name, family: activePet.hunterPet.family, alive: activePet.health > 0, health: activePet.health, shellShieldSpellId: activePet.hunterPet.shellShield?.spellId ?? null, furiousHowlSpellId: activePet.hunterPet.furiousHowl?.spellId ?? null, } : null, }, assetStatus: game.getState().assetStatus, }; }); } async function waitForGameReady(page) { await page.locator('.game-shell').waitFor({ timeout: 120_000 }); const overlay = page.locator('.loading-screen'); let showedOverlay = await overlay.isVisible().catch(() => false); if (!showedOverlay) { showedOverlay = await overlay.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true, () => false); } if (showedOverlay) await overlay.waitFor({ state: 'hidden', timeout: 180_000 }); await page.waitForFunction(async () => { const { game } = await window.hunterFamilyStores(); return game.getState().assetStatus === 'ready'; }, null, { timeout: 180_000 }); await page.locator('canvas').waitFor(); await page.waitForTimeout(800); } async function createHunter(page, name) { await page.locator('.character-select-screen').waitFor(); await page.locator('.roster-list').getByRole('button', { name: /Create character/ }).click(); await page.locator('.character-create-screen').waitFor(); await page.locator('.class-grid').getByRole('button', { name: /Hunter/ }).click(); await page.locator('.creator-specializations').getByRole('button', { name: /Marksmanship/ }).click(); await page.getByRole('button', { name: /Customize & name/ }).click(); await page.getByLabel('Character name', { exact: true }).fill(name); await page.locator('.character-create-screen').getByRole('button', { name: /Create character/ }).click(); await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).waitFor(); await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).click({ position: { x: 24, y: 30 } }); await page.locator('.main-menu-screen').waitFor(); } async function enterDefaultTestDungeon(page) { await page.getByRole('button', { name: /Dungeons/ }).click(); await page.locator('.dungeon-select-screen').waitFor(); await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click(); await selectDefaultTestDungeon(page); await page.locator('.dungeon-select-copy h2').getByText(DEFAULT_TEST_DUNGEON_TITLE, { exact: true }).waitFor(); await openSelectedDungeonRoles(page); await page.locator('.dungeon-role-option').filter({ hasText: /DPS/ }).first().click(); await waitForGameReady(page); await page.locator('.party-frames').waitFor({ timeout: 180_000 }); await page.locator('.party-frame').nth(4).waitFor({ timeout: 180_000 }); } async function activateSpellFromSpellbook(page, spellName) { await page.locator('.scene-shell').focus(); await page.keyboard.press('Escape'); await page.locator('.pause-menu').waitFor({ timeout: 15_000 }); await page.getByRole('button', { name: 'Spellbook', exact: true }).click(); const spellbook = page.locator('.spellbook-panel'); await spellbook.waitFor(); const spell = spellbook.locator('.spellbook-ability').filter({ hasText: spellName }).first(); await spell.waitFor(); assert.match(await spell.getAttribute('aria-label'), new RegExp(`${spellName}, unlocked`), `${spellName} is unlocked at the seeded level`); await spell.click(); await spellbook.waitFor({ state: 'detached' }); } async function callPet(page, expectedFamily) { await activateSpellFromSpellbook(page, 'Call Pet'); const selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]'); assert.equal(await selector.count(), 1, 'The player-controlled pet has one family selector.'); await selector.waitFor(); assert.equal(await selector.inputValue(), expectedFamily); const current = await storeSnapshot(page); assert.equal(current.combat.classId, 'hunter'); assert.ok(current.combat.level >= 10, 'Call Pet is unlocked by the documented level-10 test seed.'); assert.deepEqual(current.combat.activePet && { name: current.combat.activePet.name, family: current.combat.activePet.family, }, { name: expectedFamily === 'turtle' ? 'Turtle' : 'Wolf', family: expectedFamily }); return selector; } async function capture(page, name, fullPage = false) { await page.screenshot({ path: path.join(directory, `${name}.png`), fullPage }); console.log(`Captured ${name}`); } (async () => { const { createGameServer } = await import('../server/server.mjs'); const runtime = createGameServer({ dataDir, staticDir, contentDir, enableShelvedModes: false }); await new Promise((resolve) => runtime.server.listen(0, '127.0.0.1', resolve)); const origin = `http://127.0.0.1:${runtime.server.address().port}`; let browser; let page; const pageErrors = []; const consoleErrors = []; const httpErrors = []; const recentRequests = []; const keyEvents = []; try { browser = await chromium.launch({ channel: 'msedge', headless: true }); const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' }); page = await context.newPage(); page.setDefaultTimeout(90_000); page.on('pageerror', (error) => pageErrors.push(error.message)); page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() }); }); page.on('request', (request) => { recentRequests.push({ method: request.method(), url: request.url() }); if (recentRequests.length > 100) recentRequests.shift(); }); page.on('response', (response) => { if (response.status() >= 400) httpErrors.push({ status: response.status(), url: response.url() }); }); page.on('requestfailed', (request) => httpErrors.push({ status: 'failed', url: request.url(), failure: request.failure()?.errorText })); await page.addInitScript(({ mainModule, combatModule }) => { window.__hunterKeyEvents = []; window.addEventListener('keydown', (event) => { window.__hunterKeyEvents.push({ key: event.key, code: event.code, target: event.target?.nodeName ?? null }); }, true); window.hunterFamilyStores = async () => { const exports = [...Object.values(await import(mainModule)), ...Object.values(await import(combatModule))]; const stateOf = (value) => { try { return value?.getState?.(); } catch { return undefined; } }; return { shell: exports.find((value) => stateOf(value) && 'characters' in stateOf(value) && 'selectedCharacterId' in stateOf(value)), combat: exports.find((value) => stateOf(value) && 'castAbility' in stateOf(value) && 'summons' in stateOf(value)), game: exports.find((value) => stateOf(value) && 'assetStatus' in stateOf(value) && 'openOverlay' in stateOf(value)), simulation: exports.find((value) => stateOf(value) && 'sessionMode' in stateOf(value) && 'runState' in stateOf(value)), biteSizedLayout: exports.find((value) => stateOf(value) && 'panelFocused' in stateOf(value) && 'editorMode' in stateOf(value)), }; }; }, { mainModule, combatModule }); await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 120_000 }); await page.getByRole('button', { name: /Play offline/ }).click(); await page.locator('.character-select-screen').waitFor(); await createHunter(page, 'Shellguard'); await page.getByRole('button', { name: 'Change character', exact: true }).click(); await createHunter(page, 'Wolfguard'); // QA-only eligibility seed: after normal UI creation, advance both temporary // local profiles to level 10 through the repository-backed progression API. // Family selection and persistence are exercised only through the rendered UI. const seededProfiles = await page.evaluate(async () => { const { shell } = await window.hunterFamilyStores(); const names = ['Shellguard', 'Wolfguard']; const seeded = []; for (const name of names) { const profile = shell.getState().characters.find((character) => character.name === name); if (!profile) throw new Error(`Missing UI-created test character ${name}.`); shell.setState({ activeCharacter: profile }); shell.getState().saveActiveProgression(10, 0, profile.talentRanks, profile.glyphIds); shell.setState({ activeCharacter: null }); const saved = shell.getState().characters.find((character) => character.id === profile.id); if (saved?.level !== 10) throw new Error(`${name} did not reach the level-10 QA fixture.`); seeded.push({ name, level: saved.level, family: saved.hunterPetFamily ?? 'wolf' }); } return seeded; }); assert.deepEqual(seededProfiles, [ { name: 'Shellguard', level: 10, family: 'wolf' }, { name: 'Wolfguard', level: 10, family: 'wolf' }, ]); await page.getByRole('button', { name: 'Change character', exact: true }).click(); await page.locator('.character-select-screen').waitFor(); await page.locator('.roster-character').filter({ hasText: 'Shellguard' }).click(); await page.getByRole('button', { name: /Continue as Shellguard/ }).click({ position: { x: 24, y: 30 } }); await page.locator('.main-menu-screen').waitFor(); await enterDefaultTestDungeon(page); let selector = page.locator('.hunter-pet-controls select[aria-label="Pet family"]'); await selector.waitFor(); await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached', timeout: 30_000 }); await page.waitForTimeout(300); const entranceLock = { disabled: await selector.isDisabled(), selected: await selector.inputValue(), engagedMobs: await page.evaluate(async () => { const { combat } = await window.hunterFamilyStores(); return Object.values(combat.getState().mobs) .filter((mob) => mob.engaged && !mob.dead && !mob.despawned) .map((mob) => ({ id: mob.id, name: mob.name })); }), }; assert.equal(entranceLock.selected, 'wolf', 'A new eligible Hunter enters with its default Wolf.'); if (entranceLock.disabled) { await capture(page, '01-stockade-entry-combat-locked-desktop'); await page.setViewportSize({ width: 390, height: 844 }); await page.waitForTimeout(350); await capture(page, '02-stockade-entry-combat-locked-narrow'); await page.setViewportSize({ width: 1440, height: 1000 }); } // Entrance mobs must die through the ordinary party simulation before the // OOC family selector is used. This bounded wait never changes encounter state. await page.waitForFunction(() => { const select = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]'); return Boolean(select && !select.disabled); }, null, { timeout: 60_000 }); assert.equal(await selector.isEnabled(), true, 'Family selection unlocks naturally when the entrance encounter ends.'); const outOfCombatMobs = await page.evaluate(async () => { const { combat } = await window.hunterFamilyStores(); return Object.values(combat.getState().mobs) .filter((mob) => mob.engaged && !mob.dead && !mob.despawned) .map((mob) => ({ id: mob.id, name: mob.name })); }); assert.deepEqual(outOfCombatMobs, [], 'Turtle is selected only after the actual encounter has ended.'); await selector.selectOption('turtle'); await page.waitForFunction(async () => { const { shell, combat } = await window.hunterFamilyStores(); const profile = shell.getState().characters.find((character) => character.name === 'Shellguard'); const pet = combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__'); return profile?.hunterPetFamily === 'turtle' && combat.getState().hunterPetFamily === 'turtle' && pet?.name === 'Turtle' && pet.hunterPet.family === 'turtle' && !pet.hunterPet.furiousHowl; }, null, { timeout: 10_000 }); let current = await storeSnapshot(page); assert.equal(current.selectedCharacter?.hunterPetFamily, 'turtle'); assert.equal(current.combat.activePet?.shellShieldSpellId, 26_064); assert.equal(current.combat.activePet?.furiousHowlSpellId, null); const canvasSize = await page.locator('canvas').first().evaluate((element) => ({ width: element.width, height: element.height })); assert.ok(canvasSize.width > 0 && canvasSize.height > 0, 'The rendered dungeon has an active WebGL canvas.'); await page.setViewportSize({ width: 1440, height: 1000 }); await capture(page, '03-turtle-family-desktop'); await page.setViewportSize({ width: 390, height: 844 }); await page.waitForTimeout(350); const narrowLayout = await page.evaluate(() => { const selector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]'); const petFrame = selector?.closest('.summon-frame'); const partyFrames = document.querySelector('.party-frames'); const topRightHud = document.querySelector('.hud-top-right'); const rect = (element) => { if (!element) return null; const { top, right, bottom, left, width, height } = element.getBoundingClientRect(); return { top, right, bottom, left, width, height }; }; const overlaps = (first, second) => Boolean(first && second && first.left < second.right && first.right > second.left && first.top < second.bottom && first.bottom > second.top); const controlRect = rect(selector); const petRect = rect(petFrame); const partyRect = rect(partyFrames); const topRightRect = rect(topRightHud); return { width: innerWidth, documentWidth: document.documentElement.scrollWidth, horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1, selectorVisibleInViewport: Boolean(controlRect && controlRect.width > 0 && controlRect.height > 0 && controlRect.left >= 0 && controlRect.right <= innerWidth && controlRect.top >= 0 && controlRect.bottom <= innerHeight), partyFrameRect: partyRect, petFrameRect: petRect, selectorRect: controlRect, partyClearance: partyRect && petRect ? petRect.top - partyRect.bottom : null, overlapsPartyFrames: overlaps(petRect, partyRect), overlapsTopRightHud: overlaps(petRect, topRightRect), }; }); assert.equal(narrowLayout.horizontalOverflow, false, 'The narrow HUD has no horizontal overflow.'); assert.equal(narrowLayout.selectorVisibleInViewport, true, 'The narrow family selector remains visible.'); assert.ok(narrowLayout.partyClearance >= 8, 'The player pet card sits below the party stack with clearance.'); assert.equal(narrowLayout.overlapsTopRightHud, false, 'The player pet card does not overlap buffs/debuffs or minimap HUD.'); await capture(page, '04-turtle-family-narrow'); await page.setViewportSize({ width: 1440, height: 1000 }); await page.reload({ waitUntil: 'domcontentloaded' }); await page.locator('.character-select-screen').waitFor(); let afterReload = await page.evaluate(async () => { const { shell } = await window.hunterFamilyStores(); return shell.getState().characters.map((character) => ({ name: character.name, family: character.hunterPetFamily ?? null, level: character.level, })); }); assert.equal(afterReload.find((profile) => profile.name === 'Shellguard')?.family, 'turtle'); assert.equal(afterReload.find((profile) => profile.name === 'Wolfguard')?.family, 'wolf'); assert.deepEqual(pageErrors, [], 'No new browser page errors occurred.'); const unexpectedConsoleErrors = consoleErrors.filter((entry) => !entry.location.url.endsWith('/favicon.ico')); assert.deepEqual(unexpectedConsoleErrors, [], 'No browser console errors beyond the local server’s missing favicon occurred.'); fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ passed: true, server: 'ephemeral localhost server with disposable OS-temp data directory and isolated Playwright context', characterSeed: 'Two Marksmanship Hunters created through the normal UI; both advanced to level 10 through shellStore.saveActiveProgression in the isolated QA fixture so sourced Call Pet (level 10) and Revive Pet (level 10) are available.', scope: 'Manual Hunter family UI mechanics diagnostic only in disposable Cellblock Rush. Not a Hunter balance certification or Ready-party certification.', familySelectionProof: 'Turtle was selected through the rendered Pet family select while the live runtime reported no engaged mobs; the selected character’s saved value was re-read after page reload, and a separate UI-created Hunter remained Wolf.', combatLockProof: entranceLock.disabled ? 'At entry, live Cellblock Rush mobs were engaged and the rendered family selector was disabled; later it enabled only after the real encounter ended.' : 'The rendered family selector was enabled at the first post-intro observation with no engaged mobs. A separate earlier diagnostic screenshot and failure.json record the selector disabled while the entrance mobs were engaged; there is no narrow combat-lock screenshot.', callAndRevive: 'Spellbook Call Pet / Revive Pet activation was not covered; the Escape shortcut reached the focused scene but did not open the pause menu in this route. Neither spell was activated by state injection.', visual: { canvasSize, screenshots: [ '01-stockade-entry-combat-locked-desktop.png', '02-stockade-entry-combat-locked-narrow.png', '03-turtle-family-desktop.png', '04-turtle-family-narrow.png', ] }, entranceLock, outOfCombatMobs, narrowLayout, afterReload: afterReload, finalState: current, pageErrors, consoleErrors, httpErrors, }, null, 2)}\n`); console.log(`Hunter family browser playtest passed. Evidence: ${directory}`); } catch (error) { if (page) { await page.screenshot({ path: path.join(directory, 'failure.png'), fullPage: true }).catch(() => undefined); console.log((await page.locator('body').innerText().catch(() => '')).slice(-2_000)); } console.log('Browser page errors:', pageErrors); console.log('Browser console errors:', consoleErrors); console.log('HTTP errors:', httpErrors); console.log('Recent requests:', recentRequests.slice(-30)); const diagnostic = await page?.evaluate(async () => { const { shell, combat, game, simulation, biteSizedLayout } = await window.hunterFamilyStores(); const runtime = combat.getState(); const familySelector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]'); return { keyEvents: window.__hunterKeyEvents ?? [], focusedElement: { tag: document.activeElement?.tagName, className: document.activeElement?.className }, game: { overlay: game.getState().overlay, dungeonIntroActive: game.getState().dungeonIntroActive, assetStatus: game.getState().assetStatus }, inputContext: { sessionMode: simulation?.getState().sessionMode ?? null, runState: simulation?.getState().runState ?? null, biteSizedPanelFocused: biteSizedLayout?.getState().panelFocused ?? null, biteSizedEditorMode: biteSizedLayout?.getState().editorMode ?? null, }, sessionMode: window.localStorage.getItem('healer-man-dungeon-simulation'), familySelector: familySelector ? { value: familySelector.value, disabled: familySelector.disabled, visible: Boolean(familySelector.getClientRects().length), } : null, engagedMobs: Object.values(runtime.mobs).filter((mob) => mob.engaged && !mob.dead && !mob.despawned).map((mob) => ({ id: mob.id, name: mob.name })), family: runtime.hunterPetFamily, pets: runtime.summons.filter((summon) => summon.hunterPet && summon.ownerActorId === '__player__').map((summon) => ({ id: summon.id, kind: summon.kind, name: summon.name, family: summon.hunterPet?.family, alive: summon.health > 0 })), profiles: shell.getState().characters.map((character) => ({ name: character.name, family: character.hunterPetFamily ?? null, level: character.level })), }; }).catch((diagnosticError) => ({ error: diagnosticError.message })); let narrowEvidence = null; if (page) { await page.screenshot({ path: path.join(directory, '01-stockade-entry-desktop-diagnostic.png'), fullPage: false }).catch(() => undefined); await page.setViewportSize({ width: 390, height: 844 }).catch(() => undefined); await page.waitForTimeout(350).catch(() => undefined); narrowEvidence = await page.evaluate(() => { const rect = (element) => { if (!element) return null; const { top, right, bottom, left, width, height } = element.getBoundingClientRect(); return { top, right, bottom, left, width, height }; }; const selector = document.querySelector('.hunter-pet-controls select[aria-label="Pet family"]'); const petFrame = selector?.closest('.summon-frame'); const partyFrames = document.querySelector('.party-frames'); const selectorRect = rect(selector); return { width: innerWidth, documentWidth: document.documentElement.scrollWidth, horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1, selectorRect, selectorVisibleInViewport: Boolean(selectorRect && selectorRect.width > 0 && selectorRect.height > 0 && selectorRect.left >= 0 && selectorRect.right <= innerWidth && selectorRect.top >= 0 && selectorRect.bottom <= innerHeight), partyFrameRect: rect(partyFrames), petFrameRect: rect(petFrame), partyClearance: partyFrames && petFrame ? petFrame.getBoundingClientRect().top - partyFrames.getBoundingClientRect().bottom : null, }; }).catch(() => null); await page.screenshot({ path: path.join(directory, '02-stockade-entry-narrow-diagnostic.png'), fullPage: false }).catch(() => undefined); } const failure = { passed: false, status: error.message.includes('party stack') ? 'failed_narrow_hud_layout_assertion' : 'failed_assertion', error: { name: error.name, message: error.message }, diagnostic, narrowEvidence, screenshots: ['failure.png', '01-stockade-entry-desktop-diagnostic.png', '02-stockade-entry-narrow-diagnostic.png'], consoleErrors, httpErrors, recentRequests: recentRequests.slice(-30), }; fs.writeFileSync(path.join(directory, 'failure.json'), `${JSON.stringify(failure, null, 2)}\n`); throw error; } finally { await browser?.close(); await new Promise((resolve) => runtime.server.close(resolve)); cleanupDataDir(); } })().catch((error) => { console.error(error); process.exitCode = 1; });