Files
healer-man/scripts/playtest-hunter-cat.cjs
T
2026-10-06 22:25:34 -04:00

623 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_CAT_PLAYTEST_DIR || 'playtest-artifacts/hunter-cat');
const staticDir = path.resolve('dist');
const contentDir = path.resolve('content');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-hunter-cat-'));
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-cat-')) {
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 ? {
id: activePet.id,
ownerActorId: activePet.ownerActorId,
name: activePet.name,
family: activePet.hunterPet.family,
alive: activePet.health > 0,
health: activePet.health,
primary: {
name: activePet.hunterPet.abilityName,
spellId: activePet.hunterPet.spellId,
rank: activePet.hunterPet.rank,
},
rake: activePet.hunterPet.rake ? {
spellId: activePet.hunterPet.rake.spellId,
rank: activePet.hunterPet.rake.rank,
requiredLevel: activePet.hunterPet.rake.requiredLevel,
} : null,
prowl: activePet.hunterPet.prowl ? {
spellId: activePet.hunterPet.prowl.spellId,
rank: activePet.hunterPet.prowl.rank,
requiredLevel: activePet.hunterPet.prowl.requiredLevel,
} : null,
shellShieldSpellId: activePet.hunterPet.shellShield?.spellId ?? null,
furiousHowlSpellId: activePet.hunterPet.furiousHowl?.spellId ?? null,
command: activePet.hunterPetCommand ?? 'follow',
reactMode: activePet.hunterPetReactMode ?? 'defensive',
growlAutocast: Boolean(activePet.hunterPetGrowlAutocastEnabled),
} : null,
},
assetStatus: game.getState().assetStatus,
};
});
}
async function waitForGameReady(page) {
await page.locator('.game-shell').waitFor({ timeout: 120_000 });
const isRenderedAndReady = async () => page.evaluate(async () => {
const { game } = await window.hunterFamilyStores();
const overlay = document.querySelector('.loading-screen');
const style = overlay ? getComputedStyle(overlay) : null;
const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden'
&& overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0);
const canvas = document.querySelector('canvas');
return ['ready', 'proxy'].includes(game.getState().assetStatus)
&& !overlayVisible
&& Boolean(canvas && canvas.width > 0 && canvas.height > 0 && canvas.dataset.engine);
});
await page.waitForFunction(async () => {
const { game } = await window.hunterFamilyStores();
const overlay = document.querySelector('.loading-screen');
const style = overlay ? getComputedStyle(overlay) : null;
const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden'
&& overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0);
const canvas = document.querySelector('canvas');
return ['ready', 'proxy'].includes(game.getState().assetStatus)
&& !overlayVisible
&& Boolean(canvas && canvas.width > 0 && canvas.height > 0 && canvas.dataset.engine);
}, null, { timeout: 180_000 });
await page.waitForTimeout(1_500);
if (!await isRenderedAndReady()) {
await page.waitForFunction(async () => {
const { game } = await window.hunterFamilyStores();
const overlay = document.querySelector('.loading-screen');
const style = overlay ? getComputedStyle(overlay) : null;
const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden'
&& overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0);
const canvas = document.querySelector('canvas');
return ['ready', 'proxy'].includes(game.getState().assetStatus)
&& !overlayVisible
&& Boolean(canvas && canvas.width > 0 && canvas.height > 0 && canvas.dataset.engine);
}, null, { timeout: 180_000 });
}
await page.locator('canvas').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 180_000 }).catch(() => undefined);
}
async function renderedReadiness(page) {
return page.evaluate(async () => {
const { game } = await window.hunterFamilyStores();
const overlay = document.querySelector('.loading-screen');
const style = overlay ? getComputedStyle(overlay) : null;
const overlayVisible = Boolean(overlay && style?.display !== 'none' && style?.visibility !== 'hidden'
&& overlay.getBoundingClientRect().width > 0 && overlay.getBoundingClientRect().height > 0);
const canvas = document.querySelector('canvas');
return {
assetStatus: game.getState().assetStatus,
dungeonIntroActive: game.getState().dungeonIntroActive,
loadingScreenVisible: overlayVisible,
canvasEngine: canvas?.dataset.engine ?? null,
canvasSize: canvas ? { width: canvas.width, height: canvas.height } : null,
};
});
}
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 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, 'Catguard');
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 = ['Catguard', '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: 'Catguard', 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: 'Catguard' }).click();
await page.getByRole('button', { name: /Continue as Catguard/ }).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();
const bossIntro = page.locator('.dungeon-boss-intro');
const bossIntroObserved = await bossIntro.waitFor({ state: 'visible', timeout: 12_000 }).then(() => true, () => false);
if (bossIntroObserved) await bossIntro.waitFor({ state: 'detached', timeout: 30_000 });
await waitForGameReady(page);
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, [], 'Cat is selected only after the actual encounter has ended.');
const familyPointerTarget = await selector.evaluate((element) => {
const bounds = element.getBoundingClientRect();
const hit = document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2);
return { hitTag: hit?.tagName ?? null, hitClassName: hit?.className?.toString?.() ?? null,
hitControls: Boolean(hit?.closest('.hunter-pet-controls') === element.closest('.hunter-pet-controls')) };
});
assert.equal(familyPointerTarget.hitControls, true, 'The visible family selector receives normal pointer hit testing.');
await selector.click();
await page.keyboard.press('End');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => {
const { shell, combat } = await window.hunterFamilyStores();
const profile = shell.getState().characters.find((character) => character.name === 'Catguard');
const pet = combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__');
return profile?.hunterPetFamily === 'cat'
&& combat.getState().hunterPetFamily === 'cat'
&& pet?.name === 'Cat'
&& pet.hunterPet.family === 'cat'
&& pet.hunterPet.abilityName === 'Claw'
&& pet.hunterPet.rake?.spellId === 59881
&& pet.hunterPet.prowl?.spellId === 24450
&& !pet.hunterPet.furiousHowl
&& !pet.hunterPet.shellShield;
}, null, { timeout: 10_000 });
let current = await storeSnapshot(page);
assert.equal(current.selectedCharacter?.hunterPetFamily, 'cat');
assert.equal(current.combat.activePet?.id, '__player__:summon-1');
assert.equal(current.combat.activePet?.ownerActorId, '__player__');
assert.deepEqual(current.combat.activePet?.primary, { name: 'Claw', spellId: 16_828, rank: 2 });
assert.deepEqual(current.combat.activePet?.rake, { spellId: 59_881, rank: 1, requiredLevel: 1 });
assert.deepEqual(current.combat.activePet?.prowl, { spellId: 24_450, rank: 1, requiredLevel: 1 });
assert.equal(current.combat.activePet?.furiousHowlSpellId, null);
assert.equal(current.combat.activePet?.shellShieldSpellId, null);
await waitForGameReady(page);
const renderReadyProof = await renderedReadiness(page);
assert.ok(['ready', 'proxy'].includes(renderReadyProof.assetStatus), 'Dungeon assets are ready before visual capture.');
assert.equal(renderReadyProof.loadingScreenVisible, false, 'The actual loading screen is hidden before visual capture.');
assert.ok(renderReadyProof.canvasEngine, 'The rendered 3D canvas is present before visual capture.');
const canvasSize = renderReadyProof.canvasSize;
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-cat-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-cat-family-narrow');
const petControls = page.locator('.summon-frame--summon .hunter-pet-controls');
const petCommands = petControls.getByRole('group', { name: 'Pet command' });
const stayButton = petCommands.getByRole('button', { name: 'stay', exact: true });
const followButton = petCommands.getByRole('button', { name: 'follow', exact: true });
const attackButton = petCommands.getByRole('button', { name: 'attack', exact: true });
assert.equal(await petControls.count(), 1, 'The active player-owned Cat exposes its rendered controls.');
const commandPointerTarget = await petControls.evaluate((element) => {
const button = element.querySelector('button[aria-pressed="false"]');
if (!button) return null;
const bounds = button.getBoundingClientRect();
const hit = document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2);
return { button: button.textContent?.trim(), hitTag: hit?.tagName ?? null,
hitClassName: hit?.className?.toString?.() ?? null,
hitControls: Boolean(hit?.closest('.hunter-pet-controls') === element) };
});
assert.equal(commandPointerTarget.hitControls, true, 'Visible command buttons receive normal pointer hit testing.');
const attackDisabledWithoutTarget = await attackButton.isDisabled();
assert.equal(attackDisabledWithoutTarget, true, 'Attack is visibly unavailable without a selected hostile target.');
await stayButton.click();
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetCommand === 'stay';
}, null, { timeout: 5_000 });
await followButton.click();
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetCommand === 'follow';
}, null, { timeout: 5_000 });
const reactSelector = petControls.getByLabel('Pet reaction');
await reactSelector.click();
await page.keyboard.press('End');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetReactMode === 'passive';
}, null, { timeout: 5_000 });
await reactSelector.click();
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetReactMode === 'aggressive';
}, null, { timeout: 5_000 });
await reactSelector.click();
await page.keyboard.press('Home');
await page.keyboard.press('Enter');
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetReactMode === 'defensive';
}, null, { timeout: 5_000 });
const growlToggle = petControls.locator('.hunter-pet-controls__growl input[type="checkbox"]');
await growlToggle.click();
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetGrowlAutocastEnabled === true;
}, null, { timeout: 5_000 });
await growlToggle.click();
await page.waitForFunction(async () => {
const { combat } = await window.hunterFamilyStores();
return combat.getState().summons.find((summon) => summon.hunterPet && summon.ownerActorId === '__player__')?.hunterPetGrowlAutocastEnabled === false;
}, null, { timeout: 5_000 });
const commandReactProof = {
rendered: await petControls.count() === 1,
pointerActivation: 'Stay, follow, reaction changes, and Growl autocast were exercised on the rendered controls after hit-testing them against the controls card.',
pointerHitTest: commandPointerTarget,
staySelected: await stayButton.getAttribute('aria-pressed'),
followSelectedAfterRestore: await followButton.getAttribute('aria-pressed'),
attackDisabledWithoutTarget,
reactionValuesTested: ['passive', 'aggressive', 'defensive'],
growlAutocastToggledAndRestored: true,
restoredReaction: await reactSelector.inputValue(),
};
// Try the ordinary Escape -> pause menu -> Spellbook route once. If it does
// not open in this input context, stop instead of calling store actions.
const spellRoute = { escapePressed: false, pauseOpened: false, spellbookOpened: false, callPet: 'not attempted' };
await page.setViewportSize({ width: 1440, height: 1000 });
await page.locator('.scene-shell').focus().catch(() => undefined);
await page.keyboard.press('Escape').catch(() => undefined);
spellRoute.escapePressed = true;
spellRoute.pauseOpened = await page.locator('.pause-menu').isVisible().catch(() => false);
if (spellRoute.pauseOpened) {
await page.getByRole('button', { name: 'Spellbook', exact: true }).click();
const spellbook = page.locator('.spellbook-panel');
await spellbook.waitFor({ timeout: 5_000 });
spellRoute.spellbookOpened = true;
const callPetAbility = spellbook.locator('.spellbook-ability').filter({ hasText: /^Call Pet/ }).first();
if (await callPetAbility.count()) {
const label = await callPetAbility.getAttribute('aria-label');
if (label?.startsWith('Call Pet, unlocked')) {
await callPetAbility.click();
await page.waitForTimeout(600);
const afterCall = await storeSnapshot(page);
spellRoute.callPet = {
attemptedThroughRenderedSpellbook: true,
abilityLabel: label,
petId: afterCall.combat.activePet?.id ?? null,
family: afterCall.combat.activePet?.family ?? null,
overlayAfterClick: await page.locator('.spellbook-panel').isVisible().catch(() => false) ? 'spellbook' : 'closed',
};
} else spellRoute.callPet = { attemptedThroughRenderedSpellbook: false, abilityLabel: label ?? null };
} else spellRoute.callPet = { attemptedThroughRenderedSpellbook: false, reason: 'No Call Pet entry in the rendered spellbook.' };
} else {
spellRoute.callPet = { attemptedThroughRenderedSpellbook: false, reason: 'Escape did not expose the pause menu in this gameplay route.' };
}
spellRoute.revivePet = 'Not attempted: the live pet remained alive; no ordinary UI route to a naturally dead pet occurred.';
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 === 'Catguard')?.family, 'cat');
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 the level-10 family controls and Cat skill ranks could be inspected. This is eligibility setup, not earned progression.',
scope: 'Manual Hunter Cat family UI diagnostic only in disposable Cellblock Rush. Not a Hunter balance certification or Ready-party certification.',
familySelectionProof: 'Cat 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 Cat run’s first observation came after the entrance pack had ended; this run did not capture an in-combat Cat-family lock state. Earlier Hunter family QA covered the lock with Wolf/Turtle.',
callAndRevive: spellRoute,
commandReactProof,
catAbilityProof: 'The active player-owned pet runtime profile reports Claw 16828 rank 2, Rake 59881 rank 1, and Prowl 24450 rank 1 at level 10. It has neither Bite, Furious Howl, nor Shell Shield. This profile/readiness evidence does not prove live Rake or Prowl casts.',
visual: { renderReadyProof, bossIntroObserved, screenshots: [
...(entranceLock.disabled ? [
'01-stockade-entry-combat-locked-desktop.png',
'02-stockade-entry-combat-locked-narrow.png',
] : []),
'03-cat-family-desktop.png',
'04-cat-family-narrow.png',
] },
entranceLock,
outOfCombatMobs,
narrowLayout,
afterReload: afterReload,
finalState: current,
secondaryCharacter: afterReload.find((profile) => profile.name === 'Wolfguard'),
pageErrors,
consoleErrors,
httpErrors,
}, null, 2)}\n`);
console.log(`Hunter Cat 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; });