Files
healer-man/scripts/playtest-stat-rulesets.cjs
T
2026-10-04 22:51:21 -04:00

747 lines
42 KiB
JavaScript

const { 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');
// Run only after a fresh production build. All account and server data is isolated
// under this script's temporary directory; screenshots/results are the only output.
const directory = path.resolve('playtest-artifacts/stat-rulesets');
const staticDir = path.resolve('dist');
const contentDir = path.resolve('content');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-stat-rulesets-'));
const databaseKey = 'healer-man.profile-database.v1';
const requiredNativeRows = Object.freeze({
attributes: [
'Strength (STR)', 'Stamina (STA)', 'Intellect (INT)', 'Wisdom (WIS)', 'Agility (AGI)', 'Health', 'Mana (MP)',
],
offense: [
'Physical attack (ATK)', 'Physical damage (DMG)', 'Magical attack (MATK)', 'Magical damage (MDMG)',
'Magical damage points', 'Magic power rate', 'Physical hit (points)', 'Magical hit (points)',
'Physical critical (points)', 'Magical critical (points)', 'Healing power rate', 'Healing points',
],
defense: [
'Physical defense (DEF)', 'Magical defense (MDEF)', 'Physical dodge (points)', 'Magical dodge (points)', 'Parry (points)',
],
});
function cleanupDataDir() {
const resolvedDataDir = path.resolve(dataDir);
const resolvedTempDir = path.resolve(os.tmpdir());
if (path.dirname(resolvedDataDir) !== resolvedTempDir
|| !path.basename(resolvedDataDir).startsWith('healer-man-stat-rulesets-')) {
throw new Error(`Refusing cleanup outside dedicated QA temp directory: ${resolvedDataDir}`);
}
fs.rmSync(resolvedDataDir, { recursive: true, force: true });
}
if (!fs.existsSync(path.join(staticDir, 'index.html'))) {
cleanupDataDir();
throw new Error('A fresh production build is required before this playtest.');
}
const assetsDirectory = path.join(staticDir, 'assets');
const assets = fs.readdirSync(assetsDirectory);
const mainFile = assets.find((file) => /^index-.*\.js$/.test(file));
const combatFile = assets.find((file) => /^combatStore-.*\.js$/.test(file));
if (!mainFile || !combatFile) {
cleanupDataDir();
throw new Error('Could not locate the production app and combat-store modules in dist/assets.');
}
const moduleUrls = assets
.filter((file) => /^(?:index|combatStore|partyStore)-.*\.js$/.test(file))
.map((file) => `/assets/${file}`);
fs.mkdirSync(directory, { recursive: true });
async function capture(page, name, fullPage = false) {
const file = path.join(directory, `${name}.png`);
await page.screenshot({ path: file, fullPage });
return file;
}
async function waitForScreen(page, selector) {
await page.locator(selector).waitFor({ timeout: 180_000 });
}
async function waitForGameReady(page) {
await waitForScreen(page, '.party-frames');
await page.waitForFunction(async () => {
const stores = await window.statRulesetStores();
const overlay = document.querySelector('.loading-screen');
const visible = overlay && (() => {
const style = getComputedStyle(overlay);
const bounds = overlay.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) > 0
&& bounds.width > 0 && bounds.height > 0;
})();
const ready = stores.game?.getState().assetStatus === 'ready' && !visible;
if (!ready) {
window.__statRulesetVisualReadySince = 0;
return false;
}
if (!window.__statRulesetVisualReadySince) window.__statRulesetVisualReadySince = performance.now();
return performance.now() - window.__statRulesetVisualReadySince >= 900;
}, null, { timeout: 180_000, polling: 100 });
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 15_000 });
await page.locator('.scene-shell canvas').waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('.party-frame--player[aria-label^="Target "]').waitFor();
await page.locator('.party-frame--player .party-frame__health[aria-label="Player health"]').waitFor();
await page.waitForTimeout(1_200);
}
async function createRomPriest(page, name) {
await page.locator('.roster-new').click();
await waitForScreen(page, '.character-create-screen');
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
await page.locator('.class-option').filter({ hasText: 'Priest' }).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 waitForScreen(page, '.character-select-screen');
const created = await page.evaluate(async (characterName) => {
const { shell } = await window.statRulesetStores();
const normalizedName = characterName.replace(/[^A-Za-z]/g, '').slice(0, 12).toLowerCase();
const character = shell.getState().characters.find((entry) => entry.name.toLowerCase() === normalizedName);
return character ? { id: character.id, name: character.name, classId: character.classId, statRuleset: character.statRuleset } : null;
}, name);
assert.deepEqual(created && { classId: created.classId, statRuleset: created.statRuleset }, {
classId: 'rom-priest',
statRuleset: 'rom',
}, 'New RoM characters are created with Native RoM stats.');
return created;
}
async function chooseRosterCharacter(page, name) {
await waitForScreen(page, '.character-select-screen');
await page.locator('.roster-character').filter({ hasText: name }).click();
await page.getByRole('button', { name: new RegExp(`Continue as ${name}`) }).click({ position: { x: 24, y: 30 } });
await waitForScreen(page, '.main-menu-screen');
await page.locator('.main-menu-character').waitFor();
}
async function seedProfilesAndRemoveLegacyRule(page, ids) {
return page.evaluate(({ nativeId, legacyId, databaseKey }) => {
const database = JSON.parse(localStorage.getItem(databaseKey) || 'null');
if (!database?.rosters) throw new Error('The offline profile database was not found.');
const owners = Object.entries(database.rosters);
const nativeRecord = owners.flatMap(([, roster]) => roster).find((entry) => entry.id === nativeId);
const legacyRecord = owners.flatMap(([, roster]) => roster).find((entry) => entry.id === legacyId);
if (!nativeRecord || !legacyRecord) throw new Error('Created test profiles were missing from local storage.');
const inventory = (suffix) => [{
instanceId: `qa-robes-${suffix}`,
itemId: 10001,
itemKey: `qa-robes-${suffix}`,
name: `Field-Test Robe ${suffix}`,
quality: 'rare',
inventorySlot: 'chest',
armorType: 'cloth',
weaponType: null,
itemLevel: 30,
sourceItemLevel: 30,
stats: {
health: 0, mana: 0, strength: 0, agility: 0, stamina: 7, intellect: 3, spirit: 0,
armor: 18, attackPower: 0, rangedAttackPower: 0, spellPower: 0, criticalStrikeRating: 0,
hitRating: 0, hasteRating: 0, expertiseRating: 0, armorPenetrationRating: 0,
defenseRating: 0, dodgeRating: 0, parryRating: 0, blockRating: 0, blockValue: 0,
resilienceRating: 0, spellPenetration: 0, mp5: 0, healthPer5: 0, arcaneResistance: 0,
fireResistance: 0, frostResistance: 0, natureResistance: 0, shadowResistance: 0,
meleeWeapon: null, rangedWeapon: null,
},
quantity: 1,
sourceBossKey: 'qa-stat-ruleset-fixture',
sourceBossName: 'Stat Ruleset QA Fixture',
acquiredAt: Date.now(),
}];
const prepare = (profile, suffix, ruleset) => {
const item = inventory(suffix)[0];
Object.assign(profile, {
level: 30,
experience: 0,
secondaryClassId: 'rom-mage',
talentRanks: { 'rom-priest-qa-fixture': 1 },
glyphIds: ['qa-preserved-glyph'],
actionBindings: { 'primary-1': 'rom-priest-primary-1' },
actionLoadouts: { 'rom-priest:rom-mage': { 'primary-1': 'rom-priest-primary-1' } },
inventory: [item],
equipment: { player: { chest: item.instanceId } },
});
if (ruleset) profile.statRuleset = ruleset;
else delete profile.statRuleset;
};
prepare(nativeRecord, 'native', 'rom');
prepare(legacyRecord, 'legacy', null);
localStorage.setItem(databaseKey, JSON.stringify(database));
return {
nativeId,
legacyId,
legacyRuleRemoved: !Object.hasOwn(legacyRecord, 'statRuleset'),
fixtureItemIds: ['qa-robes-native', 'qa-robes-legacy'],
};
}, { ...ids, databaseKey });
}
async function profileSnapshot(page, id) {
return page.evaluate(async (characterId) => {
const { shell } = await window.statRulesetStores();
const profile = shell.getState().characters.find((entry) => entry.id === characterId);
if (!profile) throw new Error(`Profile ${characterId} was not found.`);
return {
id: profile.id,
name: profile.name,
classId: profile.classId,
statRuleset: profile.statRuleset,
level: profile.level,
secondaryClassId: profile.secondaryClassId,
talentRanks: profile.talentRanks,
glyphIds: profile.glyphIds,
actionBindings: profile.actionBindings,
actionLoadouts: profile.actionLoadouts,
inventory: profile.inventory,
equipment: profile.equipment,
settings: profile.settings,
dungeonRunCheckpoint: profile.dungeonRunCheckpoint,
};
}, id);
}
function buildSnapshot(profile) {
return {
level: profile.level,
secondaryClassId: profile.secondaryClassId,
talentRanks: profile.talentRanks,
glyphIds: profile.glyphIds,
actionBindings: profile.actionBindings,
actionLoadouts: profile.actionLoadouts,
inventory: profile.inventory,
equipment: profile.equipment,
settings: profile.settings,
dungeonRunCheckpoint: profile.dungeonRunCheckpoint,
};
}
async function chooseMode(page, ruleset) {
const label = ruleset === 'rom' ? 'Native RoM' : 'WoW compatibility';
const button = page.locator('.main-menu-character').getByRole('button', { name: label, exact: true });
await button.waitFor();
await button.click();
await page.waitForFunction(async (expected) => {
const { shell } = await window.statRulesetStores();
const state = shell.getState();
return state.characters.find((entry) => entry.id === state.selectedCharacterId)?.statRuleset === expected;
}, ruleset);
assert.equal(await button.getAttribute('aria-pressed'), 'true');
}
async function selectDefaultDungeon(page) {
await page.getByRole('button', { name: /Dungeons/ }).click();
await waitForScreen(page, '.dungeon-select-screen');
await page.locator('.dungeon-category-tab').filter({ hasText: 'WoW' }).click();
await selectDefaultTestDungeon(page);
await openSelectedDungeonRoles(page);
const damageRole = page.locator('.dungeon-role-option--damage');
if (await damageRole.count()) await damageRole.first().click();
else await page.locator('.dungeon-role-option').first().click();
await waitForGameReady(page);
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 120_000 });
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 15_000 });
await page.waitForTimeout(750);
}
async function openStatsPanel(page) {
const scene = page.locator('.scene-shell');
await scene.focus();
for (let attempt = 0; attempt < 2; attempt += 1) {
const overlay = await page.evaluate(async () => (await window.statRulesetStores()).game.getState().overlay);
if (overlay === 'pause') break;
await scene.press('Escape');
await page.waitForTimeout(100);
}
const overlay = await page.evaluate(async () => (await window.statRulesetStores()).game.getState().overlay);
assert.equal(overlay, 'pause', 'Escape on the focused dungeon view opens the visible pause menu.');
await page.locator('.pause-menu').waitFor();
await page.getByRole('button', { name: 'Inventory & equipment', exact: true }).click();
await page.locator('.equipment-panel').waitFor();
await page.locator('.equipment-panel .equipment-character-stats').waitFor();
}
async function readStatsPanelPage(page) {
const panel = page.locator('.equipment-panel .equipment-character-stats');
const stats = await panel.evaluate((list) => ({
label: list.getAttribute('aria-label'),
rows: [...list.querySelectorAll('div')].map((row) => ({
label: row.querySelector('dt')?.textContent?.trim() ?? '',
value: row.querySelector('dd')?.textContent?.trim() ?? '',
})),
vitals: [...document.querySelectorAll('.equipment-character-vitals span')].map((element) => element.innerText.trim()),
}));
return {
...stats,
caption: await page.locator('.equipment-character-stats__note').first().innerText().catch(() => ''),
};
}
(async () => {
let runtime;
let browser;
let page;
const pageErrors = [];
const consoleErrors = [];
const httpErrors = [];
const responseErrors = [];
const results = {
outputDirectory: directory,
buildChunks: { main: mainFile, combat: combatFile, runtimeModules: moduleUrls },
fixtures: { legacyProfileIsSynthetic: true, liveCombatGuardIsSynthetic: true, onlineActivityLock: 'attempted only if its store is exposed by production entry chunks' },
screenshots: {},
observations: {},
};
try {
const { createGameServer } = await import('../server/server.mjs');
runtime = createGameServer({ dataDir, staticDir, contentDir, enableShelvedModes: false });
await new Promise((resolve, reject) => {
runtime.server.once('error', reject);
runtime.server.listen(0, '127.0.0.1', resolve);
});
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
results.origin = origin;
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('response', (response) => {
if (response.status() >= 400) {
const error = { status: response.status(), url: response.url() };
responseErrors.push(error);
if (response.status() >= 500) httpErrors.push(error);
}
});
await page.addInitScript((urls) => {
window.statRulesetStores = async () => {
const exports = (await Promise.all(urls.map((url) => import(url)))).flatMap(Object.values);
const stores = exports.filter((value) => {
try { return typeof value?.getState === 'function'; } catch { return false; }
});
const stateOf = (value) => { try { return value?.getState?.(); } catch { return undefined; } };
return {
shell: stores.find((value) => typeof stateOf(value)?.setSelectedCharacterStatRuleset === 'function'),
combat: stores.find((value) => typeof stateOf(value)?.castAbility === 'function' && 'mobs' in stateOf(value)),
game: stores.find((value) => typeof stateOf(value)?.openOverlay === 'function' && 'assetStatus' in stateOf(value)),
party: stores.find((value) => Array.isArray(stateOf(value)?.members) && typeof stateOf(value)?.setCommand === 'function'),
online: stores.find((value) => 'snapshot' in (stateOf(value) ?? {}) && typeof stateOf(value)?.setFillWithAi === 'function'),
storeCount: stores.length,
};
};
}, moduleUrls);
await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.getByRole('button', { name: /Play offline/ }).click();
await waitForScreen(page, '.character-select-screen');
const nativeCreated = await createRomPriest(page, 'Nativestats');
const legacyCreated = await createRomPriest(page, 'Legacystats');
const seeded = await seedProfilesAndRemoveLegacyRule(page, {
nativeId: nativeCreated.id,
legacyId: legacyCreated.id,
});
assert.equal(seeded.legacyRuleRemoved, true);
results.observations.createdNativeProfile = nativeCreated;
results.observations.legacyFixture = seeded;
// A reload forces the real repository read/migration path for the old RoM save.
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForScreen(page, '.character-select-screen');
await page.locator('.character-preview canvas').waitFor({ timeout: 180_000 });
await page.locator('.roster-character').filter({ hasText: nativeCreated.name }).click();
await page.waitForTimeout(3_000);
const rosterPreview = await page.locator('.character-preview').evaluate((preview) => {
const canvas = preview.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
return {
caption: preview.querySelector('figcaption')?.innerText.trim() ?? '',
canvas: bounds ? { width: bounds.width, height: bounds.height } : null,
};
});
assert.ok(rosterPreview.canvas?.width > 100 && rosterPreview.canvas?.height > 100, 'The saved RoM character body preview has a rendered canvas.');
results.observations.rosterPreview = rosterPreview;
const migratedNative = await profileSnapshot(page, nativeCreated.id);
const migratedLegacy = await profileSnapshot(page, legacyCreated.id);
assert.equal(migratedNative.statRuleset, 'rom');
assert.equal(migratedLegacy.statRuleset, 'wow', 'A legacy saved RoM profile without the field retains WoW-compatible behavior.');
assert.equal(migratedLegacy.secondaryClassId, 'rom-mage');
assert.equal(migratedLegacy.inventory[0]?.instanceId, 'qa-robes-legacy');
assert.deepEqual(migratedLegacy.equipment.player, { chest: 'qa-robes-legacy' });
results.observations.afterLegacyMigration = {
native: { statRuleset: migratedNative.statRuleset, classId: migratedNative.classId },
legacy: { statRuleset: migratedLegacy.statRuleset, classId: migratedLegacy.classId, level: migratedLegacy.level },
savedRuleAfterMigration: JSON.parse(await page.evaluate((key) => localStorage.getItem(key), databaseKey))
.rosters['offline-roster'].find((entry) => entry.id === legacyCreated.id).statRuleset,
};
results.screenshots.rosterNative = await capture(page, '01-roster-native-rom');
await chooseRosterCharacter(page, nativeCreated.name);
await page.locator('.main-menu-character').getByRole('group', { name: 'RoM stat profile' }).waitFor();
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
const nativeBuild = buildSnapshot(await profileSnapshot(page, nativeCreated.id));
// Exercise the actual menu controls and browser persistence for one profile.
await chooseMode(page, 'wow');
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'wow');
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForScreen(page, '.character-select-screen');
await chooseRosterCharacter(page, nativeCreated.name);
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'wow', 'The visible WoW-compatible selection survives reload.');
results.screenshots.menuWowCompatibility = await capture(page, '02-menu-wow-compatibility');
await chooseMode(page, 'rom');
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
// The second RoM profile must retain its legacy WoW setting independently.
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await waitForScreen(page, '.character-select-screen');
await chooseRosterCharacter(page, legacyCreated.name);
assert.equal((await profileSnapshot(page, legacyCreated.id)).statRuleset, 'wow');
await chooseMode(page, 'rom');
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
await chooseMode(page, 'wow');
assert.equal((await profileSnapshot(page, legacyCreated.id)).statRuleset, 'wow');
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await waitForScreen(page, '.character-select-screen');
await chooseRosterCharacter(page, nativeCreated.name);
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom', 'Changing the second profile never changes the first profile.');
// Measure the selector itself at desktop and narrow phone widths.
const desktopSelectorLayout = await page.locator('.main-menu-character fieldset').evaluate((fieldset) => {
const bounds = fieldset.getBoundingClientRect();
const parent = fieldset.closest('.main-menu-sidebar').getBoundingClientRect();
const overlapsSummary = [...fieldset.parentElement.children]
.filter((element) => element !== fieldset)
.some((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && rect.left < bounds.right && rect.right > bounds.left
&& rect.top < bounds.bottom && rect.bottom > bounds.top;
});
const controls = [...fieldset.querySelectorAll('button')].map((button) => {
const rect = button.getBoundingClientRect();
return { label: button.innerText, left: rect.left, right: rect.right, width: rect.width, height: rect.height };
});
return {
clientWidth: fieldset.clientWidth,
scrollWidth: fieldset.scrollWidth,
fieldset: { left: bounds.left, right: bounds.right, width: bounds.width },
sidebar: { left: parent.left, right: parent.right },
overlapsSummary,
controls,
};
});
assert.ok(desktopSelectorLayout.scrollWidth <= desktopSelectorLayout.clientWidth + 1, 'Desktop selector content fits its fieldset.');
assert.equal(desktopSelectorLayout.overlapsSummary, false, 'Desktop selector does not overlap its character label.');
results.observations.desktopSelectorLayout = desktopSelectorLayout;
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
const narrowSelectorLayout = await page.locator('.main-menu-character fieldset').evaluate((fieldset) => {
const bounds = fieldset.getBoundingClientRect();
const parent = fieldset.closest('.main-menu-sidebar').getBoundingClientRect();
const overlapsSummary = [...fieldset.parentElement.children]
.filter((element) => element !== fieldset)
.some((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && rect.left < bounds.right && rect.right > bounds.left
&& rect.top < bounds.bottom && rect.bottom > bounds.top;
});
return {
viewportWidth: window.innerWidth,
documentWidth: document.documentElement.scrollWidth,
clientWidth: fieldset.clientWidth,
scrollWidth: fieldset.scrollWidth,
fieldset: { left: bounds.left, right: bounds.right, width: bounds.width },
sidebar: { left: parent.left, right: parent.right },
overlapsSummary,
controls: [...fieldset.querySelectorAll('button')].map((button) => {
const rect = button.getBoundingClientRect();
return { label: button.innerText, left: rect.left, right: rect.right, width: rect.width, height: rect.height };
}),
};
});
assert.ok(narrowSelectorLayout.fieldset.left >= narrowSelectorLayout.sidebar.left - 1);
assert.ok(narrowSelectorLayout.fieldset.right <= narrowSelectorLayout.sidebar.right + 1);
assert.ok(narrowSelectorLayout.scrollWidth <= narrowSelectorLayout.clientWidth + 1, 'Phone selector does not overflow its fieldset.');
assert.equal(narrowSelectorLayout.overlapsSummary, false, 'Phone selector does not overlap its character label.');
assert.ok(narrowSelectorLayout.controls.every((control) => control.left >= narrowSelectorLayout.fieldset.left - 1
&& control.right <= narrowSelectorLayout.fieldset.right + 1), 'Phone selector buttons stay inside the fieldset.');
results.observations.narrowSelectorLayout = narrowSelectorLayout;
results.screenshots.menuSelectorMobile = await capture(page, '03-menu-selector-mobile', true);
await page.setViewportSize({ width: 1440, height: 1000 });
await selectDefaultDungeon(page);
results.screenshots.nativeDungeonGameplay = await capture(page, '04-native-dungeon-gameplay-overlays-closed');
const nativeRuntime = await page.evaluate(async () => {
const { shell, combat, party } = await window.statRulesetStores();
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
const state = combat.getState();
return {
phase: shell.getState().phase,
profile: profile && { name: profile.name, statRuleset: profile.statRuleset },
classId: state.classId,
statRuleset: state.statRuleset,
health: state.health,
maxHealth: state.maxHealth,
resource: state.resource,
maxResource: state.maxResource,
resourceName: state.resourceName,
resourcePools: state.resourcePools,
inventory: state.inventory.map((item) => ({ instanceId: item.instanceId, name: item.name })),
playerEquipment: state.equipment.player,
party: party?.getState().members.map((member) => ({
name: member.name,
classId: member.classId,
statRuleset: member.statRuleset ?? 'wow',
})) ?? [],
};
});
assert.equal(nativeRuntime.phase, 'game');
assert.equal(nativeRuntime.profile?.statRuleset, 'rom');
assert.equal(nativeRuntime.classId, 'rom-priest');
assert.equal(nativeRuntime.statRuleset, 'rom', 'The selected profile reaches the actual combat runtime.');
assert.ok(nativeRuntime.maxHealth > 0 && nativeRuntime.maxResource > 0);
assert.ok(nativeRuntime.inventory.some((item) => item.instanceId === 'qa-robes-native'));
assert.equal(nativeRuntime.playerEquipment.chest, 'qa-robes-native', 'The equipped test item reaches the actual dungeon runtime.');
for (const member of nativeRuntime.party) {
if (member.classId.startsWith('rom-')) {
assert.equal(member.statRuleset, 'rom', `${member.name} keeps its own RoM ruleset.`);
assert.ok(['rom-knight', 'rom-priest', 'rom-rogue', 'rom-scout', 'rom-mage'].includes(member.classId),
'Ordinary generated RoM companions stay inside the active five classes.');
}
}
results.observations.nativeDungeonRuntime = nativeRuntime;
await openStatsPanel(page);
const trainingGearText = await page.locator('.equipment-loadout').innerText();
assert.match(trainingGearText, /Training gear scales automatically through level 79\./);
assert.match(trainingGearText, /Training gear 30/i);
assert.doesNotMatch(trainingGearText, /\bHeirloom\b/i, 'RoM automatic gear uses training labels, not authentic heirloom claims.');
results.observations.romTrainingGearLabels = true;
const nativePanelPages = {};
for (const [index, pageName] of ['attributes', 'offense', 'defense'].entries()) {
if (pageName !== 'attributes') {
const tab = page.locator('.equipment-stat-tabs').getByRole('tab', { name: pageName, exact: true });
await tab.click();
await page.waitForFunction((name) => {
const selected = document.querySelector('.equipment-stat-tabs [role="tab"][aria-selected="true"]');
return selected?.textContent?.trim() === name;
}, pageName);
}
await page.locator('.equipment-panel .equipment-character-stats').scrollIntoViewIfNeeded();
const panelPage = await readStatsPanelPage(page);
const labels = new Set(panelPage.rows.map((row) => row.label));
const missing = requiredNativeRows[pageName].filter((label) => !labels.has(label));
assert.deepEqual(missing, [], `Native RoM ${pageName} page is missing fields: ${missing.join(', ')}`);
assert.equal(panelPage.caption, 'Native RoM stats');
nativePanelPages[pageName] = panelPage;
results.screenshots[`nativeStats${pageName[0].toUpperCase()}${pageName.slice(1)}Desktop`] = await capture(
page,
`04-native-stats-${pageName}-desktop`,
);
}
const renderedMeters = await page.locator('.party-frame--player').evaluate((frame) => {
const health = frame.querySelector('.party-frame__health[aria-label="Player health"]');
const resource = frame.querySelector('.party-frame__resource');
return {
health: {
current: Number(health?.getAttribute('aria-valuenow')),
maximum: Number(health?.getAttribute('aria-valuemax')),
text: health?.querySelector('b')?.textContent?.trim() ?? '',
},
resourceTitle: resource?.getAttribute('title') ?? '',
};
});
assert.ok(Math.abs(renderedMeters.health.maximum - nativeRuntime.maxHealth) <= 1, 'Rendered health maximum agrees with combat state.');
assert.ok(Math.abs(renderedMeters.health.current - nativeRuntime.health) <= 2, 'Rendered health current agrees with combat state.');
const renderedResource = renderedMeters.resourceTitle.match(/^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s+(.+)$/);
assert.ok(renderedResource, 'Rendered resource meter exposes current, maximum, and resource type.');
assert.ok(Math.abs(Number(renderedResource[2]) - nativeRuntime.maxResource) <= 1, 'Rendered resource maximum agrees with combat state.');
assert.ok(Math.abs(Number(renderedResource[1]) - Math.floor(nativeRuntime.resource)) <= 1, 'Rendered resource current agrees with combat state.');
assert.equal(renderedResource[3], nativeRuntime.resourceName, 'Rendered resource type agrees with combat state.');
const attributeValues = new Map(nativePanelPages.attributes.rows.map((row) => [row.label, Number(row.value.replaceAll(',', ''))]));
assert.ok(Math.abs(attributeValues.get('Health') - nativeRuntime.maxHealth) <= 1, 'Native panel Health agrees with combat max health.');
assert.ok(Math.abs(attributeValues.get('Mana (MP)') - nativeRuntime.maxResource) <= 1, 'Native panel Mana agrees with combat max resource.');
assert.ok(nativePanelPages.attributes.vitals.some((row) => row.includes(`Combat health ${Math.round(nativeRuntime.maxHealth).toLocaleString()}`)));
results.observations.nativePanelPages = { ...nativePanelPages, renderedMeters };
await page.locator('.equipment-stat-tabs').getByRole('tab', { name: 'attributes', exact: true }).click();
const npcMember = nativeRuntime.party.find((member) => member.classId === 'rom-mage')
?? nativeRuntime.party.find((member) => member.classId.startsWith('rom-') && member.statRuleset === 'rom');
assert.ok(npcMember, 'The live party contains a native RoM companion owner to inspect.');
const npcOwnerButton = page.locator('.equipment-panel__owners .equipment-owner').filter({ hasText: npcMember.name });
await npcOwnerButton.waitFor();
await npcOwnerButton.click();
await page.waitForFunction((name) => (
document.querySelector('.equipment-loadout > header strong')?.textContent?.trim() === name
), npcMember.name);
const npcNativeStats = await readStatsPanelPage(page);
const npcNativeLabels = new Set(npcNativeStats.rows.map((row) => row.label));
const missingNpcRows = requiredNativeRows.attributes.filter((label) => !npcNativeLabels.has(label));
assert.deepEqual(missingNpcRows, [], `Native RoM NPC attributes page is missing fields: ${missingNpcRows.join(', ')}`);
assert.equal(npcNativeStats.caption, 'Native RoM stats');
const npcTrainingGearText = await page.locator('.equipment-loadout').innerText();
assert.match(npcTrainingGearText, /Training gear scales automatically through level 79\./);
assert.match(npcTrainingGearText, /Training gear 30/i);
assert.doesNotMatch(npcTrainingGearText, /\bHeirloom\b/i, 'RoM NPC automatic gear uses training labels.');
assert.equal(npcMember?.statRuleset, 'rom', 'The selected NPC owner keeps its own native ruleset.');
results.observations.nativeNpcOwnerPanel = { member: npcMember, stats: npcNativeStats };
results.screenshots.nativeNpcStatsAttributesDesktop = await capture(page, '05-native-npc-stats-attributes-desktop');
const playerOwnerButton = page.locator('.equipment-panel__owners .equipment-owner').filter({ hasText: nativeCreated.name });
await playerOwnerButton.click();
await page.waitForFunction((name) => (
document.querySelector('.equipment-loadout > header strong')?.textContent?.trim() === name
), nativeCreated.name);
await page.locator('.equipment-panel .equipment-character-stats').scrollIntoViewIfNeeded();
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
results.screenshots.nativeDungeonStatsMobile = await capture(page, '07-native-stats-attributes-mobile', true);
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByRole('button', { name: 'Return to pause menu' }).click();
await page.locator('.pause-menu').waitFor();
// Synthetic shell-route fixture over an installed live combat runtime. It
// tests the runtime's engaged-mob guard without leaving or saving a real run.
const blockedBefore = await profileSnapshot(page, nativeCreated.id);
const syntheticLock = await page.evaluate(async () => {
const { shell, combat } = await window.statRulesetStores();
shell.setState({ phase: 'main-menu', activeCharacter: null });
combat.setState({ mobs: { '__qa-synthetic-live-mob__': { engaged: true, dead: false } } });
return { phase: shell.getState().phase, statRuleset: shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId)?.statRuleset };
});
assert.equal(syntheticLock.phase, 'main-menu');
await waitForScreen(page, '.main-menu-screen');
await page.locator('.main-menu-character').getByRole('button', { name: 'WoW compatibility', exact: true }).click();
const blockedAttempt = await page.evaluate(async () => {
const { shell } = await window.statRulesetStores();
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
return { phase: shell.getState().phase, statRuleset: profile?.statRuleset, notice: shell.getState().notice };
});
assert.equal(blockedAttempt.statRuleset, blockedBefore.statRuleset);
assert.equal((await profileSnapshot(page, nativeCreated.id)).statRuleset, 'rom');
results.observations.syntheticLiveCombatLock = {
beforeClick: syntheticLock,
afterClick: blockedAttempt,
fixture: 'Synthetic main-menu route and engaged mob injected over the local live runtime; no run was left or saved.',
};
await page.evaluate(async () => {
const { shell, combat } = await window.statRulesetStores();
combat.setState({ mobs: {} });
shell.setState({ phase: 'game', activeCharacter: null });
});
await page.locator('.pause-menu').waitFor({ timeout: 30_000 });
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
await waitForScreen(page, '.main-menu-screen');
// Optionally set a synthetic online activity if its store is exposed by the
// production entry chunks; absence is recorded rather than faked via UI state.
const onlineFixture = await page.evaluate(async () => {
const { online } = await window.statRulesetStores();
if (!online) return { available: false };
online.setState({ snapshot: {
group: { id: 'qa-synthetic-online-group', members: [], activity: { id: 'qa-synthetic-live-activity' } },
invitations: [],
} });
return { available: true };
});
if (onlineFixture.available) {
await page.waitForFunction(() => [...document.querySelectorAll('.main-menu-character button')].every((button) => button.disabled));
const blocked = await page.evaluate(async () => {
const { shell } = await window.statRulesetStores();
const accepted = shell.getState().setSelectedCharacterStatRuleset('wow');
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
return { accepted, statRuleset: profile?.statRuleset };
});
assert.equal(blocked.accepted, false);
assert.equal(blocked.statRuleset, 'rom');
const profileButtonDisabled = await page.locator('.main-menu-character [aria-label="RoM stat profile"] button').evaluateAll((buttons) => buttons.every((button) => button.disabled));
assert.equal(profileButtonDisabled, true, 'A synthetic online activity disables the profile selector.');
results.observations.syntheticOnlineActivityLock = { ...onlineFixture, ...blocked, profileButtonDisabled, fixture: 'Synthetic online group activity injected into the local store.' };
await page.evaluate(async () => (await window.statRulesetStores()).online.getState().reset());
} else {
results.observations.syntheticOnlineActivityLock = { available: false, note: 'The online-group store was not exported by production entry chunks.' };
}
// Visible WoW switch after leaving the run must preserve all saved build data.
const beforeWowSwitch = await profileSnapshot(page, nativeCreated.id);
const beforeWowBuild = buildSnapshot(beforeWowSwitch);
assert.deepEqual(
{ ...beforeWowBuild, dungeonRunCheckpoint: null },
{ ...nativeBuild, dungeonRunCheckpoint: null },
'The first dungeon run leaves the character build and inventory intact.',
);
await chooseMode(page, 'wow');
const afterWowSwitch = await profileSnapshot(page, nativeCreated.id);
assert.equal(afterWowSwitch.statRuleset, 'wow');
assert.deepEqual(buildSnapshot(afterWowSwitch), beforeWowBuild, 'Switching rulesets preserves class pair, talents, bars, inventory, gear, and checkpoint.');
assert.equal((await profileSnapshot(page, legacyCreated.id)).statRuleset, 'wow');
results.observations.persistedBuildAfterWowSwitch = buildSnapshot(afterWowSwitch);
results.screenshots.menuWowAfterDungeon = await capture(page, '06-menu-wow-after-dungeon');
// A second actual run verifies the saved compatibility selection reaches runtime.
await selectDefaultDungeon(page);
results.screenshots.wowDungeonGameplay = await capture(page, '08-wow-compatibility-dungeon-gameplay-overlays-closed');
const wowRuntime = await page.evaluate(async () => {
const { shell, combat } = await window.statRulesetStores();
const profile = shell.getState().characters.find((entry) => entry.id === shell.getState().selectedCharacterId);
const state = combat.getState();
return { statRuleset: state.statRuleset, profileRuleset: profile?.statRuleset, classId: state.classId };
});
assert.deepEqual(wowRuntime, { statRuleset: 'wow', profileRuleset: 'wow', classId: 'rom-priest' });
results.observations.wowCompatibilityDungeonRuntime = wowRuntime;
await capture(page, '07-wow-compatibility-dungeon');
await page.keyboard.press('Escape');
await page.locator('.pause-menu').waitFor();
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
await waitForScreen(page, '.main-menu-screen');
const profileBeforeFinalReload = await profileSnapshot(page, nativeCreated.id);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitForScreen(page, '.character-select-screen');
await chooseRosterCharacter(page, nativeCreated.name);
const finalNative = await profileSnapshot(page, nativeCreated.id);
const finalLegacy = await profileSnapshot(page, legacyCreated.id);
assert.equal(finalNative.statRuleset, 'wow');
assert.equal(finalLegacy.statRuleset, 'wow');
assert.deepEqual(finalNative.dungeonRunCheckpoint, profileBeforeFinalReload.dungeonRunCheckpoint,
'The saved checkpoint survives a reload alongside the selected ruleset.');
assert.deepEqual(
{ ...buildSnapshot(finalNative), dungeonRunCheckpoint: null },
{ ...nativeBuild, dungeonRunCheckpoint: null },
'Native to WoW switching and the dungeon run preserve the character build and inventory.',
);
results.observations.finalProfiles = {
native: { name: finalNative.name, classId: finalNative.classId, statRuleset: finalNative.statRuleset },
legacy: { name: finalLegacy.name, classId: finalLegacy.classId, statRuleset: finalLegacy.statRuleset },
checkpointRetainedAfterReload: Boolean(finalNative.dungeonRunCheckpoint),
};
assert.deepEqual(pageErrors, [], 'No uncaught browser errors occurred.');
assert.deepEqual(httpErrors, [], 'No production asset or server failures occurred.');
const unexpectedResponseErrors = responseErrors.filter((error) => !(error.status === 404 && new URL(error.url).pathname === '/favicon.ico'));
assert.deepEqual(unexpectedResponseErrors, [], 'No app assets or API resources failed to load (the browser may request a missing optional favicon).');
results.diagnostics = { pageErrors, consoleErrors, httpErrors, responseErrors };
results.status = 'passed';
fs.writeFileSync(path.join(directory, 'results.json'), JSON.stringify(results, null, 2));
console.log(`Stat ruleset browser playtest passed: ${directory}`);
} catch (error) {
results.status = 'failed';
results.error = error?.stack ?? String(error);
results.diagnostics = { pageErrors, consoleErrors, httpErrors, responseErrors };
if (page) {
results.screenshots.failure = await capture(page, 'failure', true).catch(() => null);
}
fs.writeFileSync(path.join(directory, 'results.json'), JSON.stringify(results, null, 2));
throw error;
} finally {
await browser?.close().catch(() => {});
if (runtime?.server.listening) await new Promise((resolve) => runtime.server.close(resolve));
cleanupDataDir();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});