Update class mechanics, stat rulesets, and CoA reference catalogs

This commit is contained in:
phenom committed 2026-10-04 12:56:30 -04:00
1 parent b16821e92e
commit 39458d5de6
221 files changed
+2584962 -568

No files matched your search

+621
View File
@@ -0,0 +1,621 @@
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 enterWailingCaverns(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 page.locator('.dungeon-select-row').filter({ hasText: 'Wailing Caverns' }).click();
await page.locator('.dungeon-select-copy h2').getByText(/^Wailing Caverns$/, { exact: true }).waitFor();
await page.getByRole('button', { name: /Choose role/ }).click();
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 enterWailingCaverns(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-wailing-entry-combat-locked-desktop');
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
await capture(page, '02-wailing-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 Wailing Caverns. 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 Wailing Caverns 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-wailing-entry-combat-locked-desktop.png',
'02-wailing-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-wailing-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-wailing-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-wailing-entry-desktop-diagnostic.png', '02-wailing-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; });