Files
healer-man/scripts/playtest-hunter-families.cjs
T

450 lines
25 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 { 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 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 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 enterWailingCaverns(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-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, [], '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 Wailing Caverns. 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 Wailing Caverns 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-wailing-entry-combat-locked-desktop.png',
'02-wailing-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-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; });