const { DEFAULT_TEST_DUNGEON_ID, DEFAULT_TEST_DUNGEON_TITLE, DEFAULT_TEST_DUNGEON_CATEGORY, FULL_STOCKADE_DUNGEON_ID, FULL_STOCKADE_DUNGEON_TITLE, } = 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'); // UI-driven campaign route QA. Only the isolated profile's level and RoM // secondary class are prepared directly; dungeon entry, movement, targeting, // combat actions, and ally selection all use the ordinary UI and keyboard. const artifactRoot = path.resolve('playtest-artifacts/native-routes'); const stamp = new Date().toISOString().replace(/[-:.]/g, '').replace('T', '-').replace('Z', ''); const baseline = process.argv.includes('--baseline'); const outputDirectory = path.join(artifactRoot, `${baseline ? 'baseline' : 'final'}-${stamp}`); const durationOption = process.argv.find((value) => value.startsWith('--route-seconds=')); const routeDurationMs = Math.max(15, Number(durationOption?.split('=')[1] ?? (baseline ? 25 : 90))) * 1_000; const routeSelection = process.argv.find((value) => value.startsWith('--route='))?.split('=')[1] ?? null; const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-native-routes-')); const moduleFiles = fs.readdirSync('dist/assets') .filter((file) => /^(?:index|GameRuntime|combatStore|partyStore|PartyFrames)-.*\.js$/.test(file)); const moduleUrls = moduleFiles.map((file) => `/assets/${file}`); const routes = [ { id: 'barren-caves', title: 'Barren Caves', category: 'RoM' }, { id: DEFAULT_TEST_DUNGEON_ID, title: DEFAULT_TEST_DUNGEON_TITLE, category: DEFAULT_TEST_DUNGEON_CATEGORY }, { id: FULL_STOCKADE_DUNGEON_ID, title: FULL_STOCKADE_DUNGEON_TITLE, category: 'WoW' }, ...(process.argv.includes('--historical-wailing') ? [{ id: 'wailing-caverns', title: 'Wailing Caverns', category: 'WoW' }] : []), ]; if (routeSelection === 'wailing-caverns' && !process.argv.includes('--historical-wailing')) { throw new Error('Historical Wailing reproduction requires an explicit owner request and --historical-wailing.'); } const routesToRun = routeSelection ? routes.filter((route) => route.id === routeSelection) : routes.filter((route) => route.id === DEFAULT_TEST_DUNGEON_ID); const actionSlots = [ ['primary:face-bottom', '1'], ['primary:face-left', '2'], ['primary:face-top', '3'], ['primary:face-right', '4'], ['primary:r1', '5'], ['primary:r2', '6'], ['primary:dpad-left', '7'], ['primary:dpad-right', '8'], ]; fs.mkdirSync(outputDirectory, { recursive: true }); function distance3(left, right) { if (!left || !right) return Number.POSITIVE_INFINITY; return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]); } function distancePlanar(left, right) { if (!left || !right) return Number.POSITIVE_INFINITY; return Math.hypot(left[0] - right[0], left[2] - right[2]); } function hasFinitePosition(position) { return Array.isArray(position) && position.length >= 3 && position.every(Number.isFinite); } function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function flatEffects(effects = []) { return effects.flatMap((effect) => effect.kind === 'composite' ? flatEffects(effect.effects) : [effect]); } function actionPlanFromSnapshot(snapshot) { const abilities = new Map(snapshot.abilities.map((ability) => [ability.id, ability])); const bound = actionSlots.flatMap(([bindingId, key]) => { const id = snapshot.actionBindings[bindingId]; const ability = id ? abilities.get(id) : null; return ability && !ability.passive && (ability.unlockLevel ?? 1) <= snapshot.player.level ? [{ key, ability, effects: flatEffects(ability.effects) }] : []; }); const damage = bound.filter(({ ability, effects }) => ability.target === 'hostile' && effects.some((effect) => effect.kind === 'damage')) .sort((left, right) => Number(right.ability.range?.max ?? 0) - Number(left.ability.range?.max ?? 0))[0] ?? bound.find(({ effects }) => effects.some((effect) => effect.kind === 'damage')); const healing = bound.find(({ ability, effects }) => ability.target === 'friendly' && effects.some((effect) => effect.kind === 'heal')); return { damage: damage ? { key: damage.key, id: damage.ability.id, name: damage.ability.name, target: damage.ability.target, range: damage.ability.range ?? null, selectionReason: 'longest-range-bound-hostile-damage-ability' } : null, healing: healing ? { key: healing.key, id: healing.ability.id, name: healing.ability.name, target: healing.ability.target, range: healing.ability.range ?? null } : null, bound: bound.map(({ key, ability }) => ({ key, id: ability.id, name: ability.name, target: ability.target, range: ability.range ?? null })), }; } async function readLiveState(page, includeActionSetup = false) { return page.evaluate((includeSetup) => window.nativeRouteStores().then(({ game, combat, party, getMobPosition, mobPositionExports, getPartyPosition, partyPositionExports }) => { const gameState = game.getState(); const combatState = combat.getState(); return { now: Date.now(), game: { mobPositionExports, partyPositionExports, activeDungeonId: gameState.activeDungeonId, gameMode: gameState.gameMode, gmTestMode: gameState.gmTestMode, bossTest: gameState.bossTest, assetStatus: gameState.assetStatus, activeSpawn: gameState.activeSpawn, playerPosition: gameState.playerPosition, playerNavigationPosition: gameState.playerNavigationPosition, playerGrounded: gameState.playerGrounded, cameraYaw: gameState.cameraYaw, areaName: gameState.areaName, distanceTravelled: gameState.distanceTravelled, hasMoved: gameState.hasMoved, overlay: gameState.overlay, dungeonIntroActive: gameState.dungeonIntroActive, }, player: { classId: combatState.classId, secondaryClassId: combatState.secondaryClassId, level: combatState.level, role: window.nativeRouteStoresRole ?? null, statRuleset: combatState.statRuleset, health: combatState.health, maxHealth: combatState.maxHealth, resource: combatState.resource, maxResource: combatState.maxResource, resourceType: combatState.resourceType, resourceName: combatState.resourceName, position: gameState.playerPosition, selectedTargetId: combatState.selectedTargetId, activeCast: combatState.activeCast?.abilityId ?? null, cooldowns: combatState.cooldowns, }, party: party.getState().members.map((member) => ({ id: member.id, name: member.name, classId: member.classId, secondaryClassId: member.secondaryClassId, role: member.role, statRuleset: member.statRuleset, health: member.health, maxHealth: member.maxHealth, resource: member.resource, maxResource: member.maxResource, resourceType: member.resourceType, status: member.status, activeCast: member.activeCast?.abilityId ?? null, position: getPartyPosition?.(member.id) ?? null, })), mobs: Object.values(combatState.mobs).filter((mob) => !mob.despawned).map((mob) => ({ id: mob.id, name: mob.name, level: mob.level, health: mob.health, maxHealth: mob.maxHealth, dead: mob.dead, diedAt: mob.diedAt ?? null, engaged: mob.engaged, position: getMobPosition?.(mob.id) ?? mob.position ?? mob.homePosition, homePosition: mob.homePosition, attackRevision: mob.attackRevision, abilityCastCounts: mob.abilityCastCounts, activeCast: mob.activeCast ? { name: mob.activeCast.name, attackId: mob.activeCast.attackId } : null, boss: mob.boss, })), ...(includeSetup ? { abilities: combatState.abilities, actionBindings: combatState.actionBindings } : {}), feedback: combatState.feedback ? { kind: combatState.feedback.kind, message: combatState.feedback.message, abilityId: combatState.feedback.abilityId ?? null, targetId: combatState.feedback.targetId ?? null } : null, events: combatState.combatEvents.slice(-200), }; }), includeActionSetup); } function liveMobs(snapshot) { return snapshot.mobs.filter((mob) => !mob.dead && !mob.despawned); } function sourcePlayerEvent(event) { return event.sourceActorId === '__player__'; } async function selectHostileWithTab(page, routeResult, attempts = 10) { for (let attempt = 0; attempt < attempts; attempt += 1) { await page.keyboard.press('Tab'); await page.waitForTimeout(80); const snapshot = await readLiveState(page); const target = snapshot.mobs.find((mob) => mob.id === snapshot.player.selectedTargetId && !mob.dead && !mob.despawned && !mob.boss && hasFinitePosition(mob.position)); routeResult.targeting.push({ at: snapshot.now, input: 'Tab', selectedTargetId: snapshot.player.selectedTargetId, targetName: target?.name ?? null, targetDistance: target ? distance3(snapshot.player.position, target.position) : null }); if (target) return { snapshot, target }; } return null; } function movementKeysTo(from, to, yaw) { const dx = to[0] - from[0]; const dz = to[2] - from[2]; const magnitude = Math.hypot(dx, dz) || 1; const worldX = dx / magnitude; const worldZ = dz / magnitude; const cosYaw = Math.cos(yaw); const sinYaw = Math.sin(yaw); const localX = -worldX * cosYaw + worldZ * sinYaw; const localForward = worldX * sinYaw + worldZ * cosYaw; const keys = []; if (localForward > 0.32) keys.push('w'); else if (localForward < -0.32) keys.push('s'); if (localX > 0.32) keys.push('d'); else if (localX < -0.32) keys.push('a'); return keys; } async function walkToward(page, routeResult, destination, durationMs = 650) { const before = await readLiveState(page); const keys = movementKeysTo(before.player.position, destination, before.game.cameraYaw); if (!keys.length) return null; for (const key of keys) await page.keyboard.down(key); try { if (routeSelection && routesToRun.length !== 1) throw new Error(`Unknown native campaign route id: ${routeSelection}`); await page.waitForTimeout(durationMs); } finally { for (const key of keys) await page.keyboard.up(key); } await page.waitForTimeout(150); const after = await readLiveState(page); const step = { at: after.now, keys: keys.map((key) => key.toUpperCase()), durationMs, from: before.player.position, to: after.player.position, destination, planarDisplacement: distancePlanar(before.player.position, after.player.position), spatialDisplacement: distance3(before.player.position, after.player.position), gameDistanceTravelledDelta: after.game.distanceTravelled - before.game.distanceTravelled, targetDistanceBefore: distance3(before.player.position, destination), targetDistanceAfter: distance3(after.player.position, destination), blocked: distancePlanar(before.player.position, after.player.position) < 0.08, }; routeResult.movement.push(step); return { snapshot: after, step }; } async function performCast(page, routeResult, action, kind, intendedTargetId, reason) { const before = await readLiveState(page); const since = before.now - 1; await page.keyboard.press(action.key); const castWaitStarted = Date.now(); let after = await readLiveState(page); const relevantEventsFor = (state) => state.events.filter((event) => event.occurredAt >= since && event.abilityId === action.id && sourcePlayerEvent(event) && (kind === 'damage' ? event.kind === 'damage' : event.kind === 'healing')); while (Date.now() - castWaitStarted < 2_600) { const relevant = relevantEventsFor(after); if (relevant.some((event) => event.effectiveAmount > 0) || (!after.player.activeCast && Date.now() - castWaitStarted >= 650)) break; await page.waitForTimeout(150); after = await readLiveState(page); } const relevantEvents = relevantEventsFor(after); const memberBefore = before.party.find((member) => member.id === intendedTargetId); const memberAfter = after.party.find((member) => member.id === intendedTargetId); const cast = { at: before.now, key: action.key, abilityId: action.id, abilityName: action.name, kind, reason, intendedTargetId, selectedTargetIdBefore: before.player.selectedTargetId, targetHealthBefore: kind === 'healing' ? before.party.find((member) => member.id === intendedTargetId)?.health ?? null : before.mobs.find((mob) => mob.id === intendedTargetId)?.health ?? null, targetHealthAfter: kind === 'healing' ? after.party.find((member) => member.id === intendedTargetId)?.health ?? null : after.mobs.find((mob) => mob.id === intendedTargetId)?.health ?? null, playerPositionAtInput: before.player.position, allyPositionAtInput: memberBefore?.position ?? null, allyDistanceAtInput: memberBefore?.position ? distance3(before.player.position, memberBefore.position) : null, allyDistanceAfterCast: memberAfter?.position ? distance3(after.player.position, memberAfter.position) : null, castWaitMs: Date.now() - castWaitStarted, resourceBefore: before.player.resource, resourceAfter: after.player.resource, resolvedEvents: relevantEvents.map((event) => ({ kind: event.kind, targetActorId: event.targetActorId, effectiveAmount: event.effectiveAmount, overheal: event.overheal, school: event.school ?? null, abilityId: event.abilityId, occurredAt: event.occurredAt })), actionResolved: relevantEvents.some((event) => event.effectiveAmount > 0), feedback: after.feedback, }; routeResult.actions.push(cast); return { before, after, cast }; } async function selectPartyMember(page, member) { const label = new RegExp(`^Target ${escapeRegex(member.name)},`); // Accessible target labels are unique for native companions; getByRole keeps // the test on the same clickable party-frame that a player would use. await page.getByRole('button', { name: label }).click(); } async function rosterContinueHitTest(page, viewport) { return page.evaluate((viewportSize) => { const button = document.querySelector('.roster-enter'); const toggle = document.querySelector('.single-context-toggle'); if (!button) throw new Error('Roster Continue button is missing.'); const buttonRect = button.getBoundingClientRect(); const toggleRect = toggle?.getBoundingClientRect() ?? null; const overlaps = Boolean(toggleRect && buttonRect.left < toggleRect.right && buttonRect.right > toggleRect.left && buttonRect.top < toggleRect.bottom && buttonRect.bottom > toggleRect.top); const center = { x: buttonRect.left + buttonRect.width / 2, y: buttonRect.top + buttonRect.height / 2 }; const hit = document.elementFromPoint(center.x, center.y); return { viewport: viewportSize, buttonRect: { x: buttonRect.x, y: buttonRect.y, width: buttonRect.width, height: buttonRect.height }, toggleRect: toggleRect ? { x: toggleRect.x, y: toggleRect.y, width: toggleRect.width, height: toggleRect.height } : null, overlaps, centerHit: hit?.className?.toString?.() ?? hit?.tagName ?? null, centerHitIsContinue: Boolean(hit && (hit === button || button.contains(hit))), }; }, viewport); } async function prepareCharacter(page) { await page.getByRole('button', { name: /Play offline/ }).click(); await page.getByRole('button', { name: /Create character/ }).click(); 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').fill('RoutePriestQA'); await page.getByRole('button', { name: /Create character/ }).click(); await page.locator('.character-select-screen').waitFor(); const profile = await page.evaluate(async () => { const { shell } = await window.nativeRouteStores(); const state = shell.getState(); const character = state.characters.find((entry) => entry.id === state.selectedCharacterId); if (!character || character.classId !== 'rom-priest') throw new Error('UI-created RoM Priest profile is missing.'); shell.setState({ activeCharacter: character }); shell.getState().saveActiveProgression(30, 0, character.talentRanks); shell.setState({ activeCharacter: null }); if (!shell.getState().chooseRomSecondaryClass('rom-knight')) throw new Error('Priest/Knight pairing was not accepted.'); const updated = shell.getState().characters.find((entry) => entry.id === character.id); return { id: updated.id, name: updated.name, classId: updated.classId, secondaryClassId: updated.secondaryClassId, level: updated.level, categoryId: updated.categoryId }; }); assert.equal(profile.classId, 'rom-priest'); assert.equal(profile.secondaryClassId, 'rom-knight'); assert.equal(profile.level, 30); await page.waitForTimeout(1_500); await page.screenshot({ path: path.join(outputDirectory, 'roster-priest-knight-level-30.png') }); const desktopRosterHit = await rosterContinueHitTest(page, { width: 1440, height: 900 }); await page.setViewportSize({ width: 390, height: 844 }); await page.waitForTimeout(300); await page.screenshot({ path: path.join(outputDirectory, 'roster-compact-priest-knight-level-30.png') }); const compactRosterHit = await rosterContinueHitTest(page, { width: 390, height: 844 }); await page.setViewportSize({ width: 1440, height: 900 }); await page.waitForTimeout(300); const rosterUi = { desktop: desktopRosterHit, compact: compactRosterHit, forceClickUsed: false }; if (!baseline) { assert.equal(desktopRosterHit.overlaps, false, 'Desktop roster toggle does not overlap Continue.'); assert.equal(desktopRosterHit.centerHitIsContinue, true, 'Desktop Continue button owns its center hit point.'); assert.equal(compactRosterHit.overlaps, false, 'Compact roster toggle does not overlap Continue.'); assert.equal(compactRosterHit.centerHitIsContinue, true, 'Compact Continue button owns its center hit point.'); } if (desktopRosterHit.centerHitIsContinue) { await page.locator('.roster-enter').click(); } else if (baseline) { // A baseline with the known floating-toggle obstruction may continue via // a forced pointer action for diagnostics, and records that exception. rosterUi.forceClickUsed = true; await page.locator('.roster-enter').click({ force: true }); } else { throw new Error('Final-build roster Continue button is obscured at its center hit point.'); } await page.locator('.main-menu-screen').waitFor(); return { profile, rosterUi }; } async function enterCampaignDungeon(page, route, isFirst, minimumLevel = 30) { if (!isFirst) { await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Main menu', exact: true }).waitFor({ state: 'visible' }); await page.getByRole('button', { name: 'Main menu', exact: true }).click(); await page.locator('.main-menu-screen').waitFor(); } await page.getByRole('button', { name: /^Dungeons/ }).click(); await page.locator('.dungeon-select-screen').waitFor(); await page.locator('.dungeon-category-tab').filter({ hasText: route.category }).click(); const exactTitle = new RegExp(`^${escapeRegex(route.title)}$`, 'i'); const row = page.locator('.dungeon-select-row').filter({ has: page.locator('strong').filter({ hasText: exactTitle }), }); const exactRowCount = await row.count(); assert.equal(exactRowCount, 1, `${route.title} has one exact source campaign row in ${route.category}`); await row.waitFor({ state: 'visible' }); const selectedRowTitle = (await row.locator('strong').innerText()).trim(); assert.equal(selectedRowTitle, route.title, `${route.title} campaign selection is exact`); await page.screenshot({ path: path.join(outputDirectory, `${route.id}-campaign-picker.png`) }); await row.click(); if (await page.getByRole('alertdialog').count()) { const confirmDownload = page.getByRole('button', { name: 'Download and continue', exact: true }); if (await confirmDownload.count()) await confirmDownload.click(); } else { await page.locator('.dungeon-select-enter').click(); if (await page.getByRole('alertdialog').count()) { await page.getByRole('button', { name: 'Download and continue', exact: true }).click(); } } await page.locator('.dungeon-role-option--healer').waitFor({ state: 'visible', timeout: 180_000 }); await page.locator('.dungeon-role-option--healer').click(); await page.locator('.party-frames').waitFor({ timeout: 180_000 }); await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 180_000 }).catch(() => {}); await page.waitForFunction(async (expectedId) => { const { game } = await window.nativeRouteStores(); const state = game.getState(); return state.activeDungeonId === expectedId && state.assetStatus === 'ready'; }, route.id, { timeout: 240_000 }); await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached', timeout: 15_000 }).catch(() => {}); await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur()); const state = await readLiveState(page); assert.equal(state.game.activeDungeonId, route.id, `${route.title} entered via the selected campaign route`); assert.equal(state.game.gameMode, 'dungeon'); assert.equal(state.game.gmTestMode, false, 'campaign entry is not GM mode'); assert.equal(state.game.bossTest, null, 'campaign entry is not Boss Testing'); assert.equal(state.player.classId, 'rom-priest'); assert.equal(state.player.secondaryClassId, 'rom-knight'); assert(state.player.level >= minimumLevel, `${route.title} entry level remains at least the UI-created profile's level ${minimumLevel}`); assert.equal(state.party.length, 4, 'the UI-created Priest has four NPC companions'); assert(state.party.every((member) => member.classId.startsWith('rom-') && member.statRuleset === 'rom'), 'all four companions use native RoM identities and rules'); await page.screenshot({ path: path.join(outputDirectory, `${route.id}-entrance.png`) }); return state; } async function runRouteSegment(page, route, entryState, profile) { const result = { dungeonId: route.id, title: route.title, coverage: 'campaign entrance through first live in-scene pack/encounter segment; no full route or boss-clear claim', enteredThrough: 'normal Dungeons picker and healer role selection', bossTesting: false, profile, playerAtEntry: entryState.player, partyAtEntry: entryState.party, dungeonEntry: entryState.game, startingPosition: entryState.player.position, actionPlan: actionPlanFromSnapshot(entryState), movement: [], healPursuits: [], targeting: [], actions: [], samples: [], observedCombatEvents: [], screenshots: { entrance: `${route.id}-entrance.png`, campaignPicker: `${route.id}-campaign-picker.png`, walk: `${route.id}-keyboard-traversal.png`, middle: `${route.id}-encounter-midpoint.png`, endpoint: `${route.id}-segment-end.png`, }, segmentStartedAt: Date.now(), }; const seenEventKeys = new Set(entryState.events.map((event) => [event.occurredAt, event.sourceActorId, event.targetActorId, event.abilityId, event.kind, event.effectiveAmount].join('|'))); const observedDeadMobs = new Map(); const collectCombatEvents = (snapshot) => { for (const mob of snapshot.mobs.filter((entry) => entry.dead)) observedDeadMobs.set(mob.id, mob.name); for (const event of snapshot.events) { const key = [event.occurredAt, event.sourceActorId, event.targetActorId, event.abilityId, event.kind, event.effectiveAmount].join('|'); if (seenEventKeys.has(key)) continue; seenEventKeys.add(key); result.observedCombatEvents.push(event); } }; const initialMobs = liveMobs(entryState).map((mob) => ({ id: mob.id, name: mob.name, level: mob.level, position: mob.position, distanceFromEntrance: distance3(entryState.player.position, mob.position), boss: mob.boss, })).sort((left, right) => left.distanceFromEntrance - right.distanceFromEntrance); result.sceneMobsAtEntry = initialMobs; const plan = result.actionPlan; if (!plan.damage) { result.bindingDiagnostic = { actionBindings: entryState.actionBindings, computedActionPlan: plan, playerLevel: entryState.player.level, boundSlots: actionSlots.map(([bindingId, key]) => ({ bindingId, key, abilityId: entryState.actionBindings[bindingId] ?? null })), rawBoundAbilities: actionSlots.map(([bindingId, key]) => { const id = entryState.actionBindings[bindingId]; const ability = entryState.abilities.find((candidate) => candidate.id === id); return { key, id, passive: ability?.passive, unlockLevel: ability?.unlockLevel, target: ability?.target, rawEffects: ability?.effects, flattenedEffects: ability ? flatEffects(ability.effects).map((effect) => effect.kind) : null }; }), abilityCount: entryState.abilities.length, availableAbilities: entryState.abilities.filter((ability) => !ability.passive && (ability.unlockLevel ?? 1) <= entryState.player.level).map((ability) => ({ id: ability.id, name: ability.name, target: ability.target, unlockLevel: ability.unlockLevel, effects: flatEffects(ability.effects).map((effect) => effect.kind) })), }; fs.writeFileSync(path.join(outputDirectory, `${route.id}-binding-diagnostic.json`), `${JSON.stringify(result.bindingDiagnostic, null, 2)}\n`); throw new Error(`${route.title}: no native damage ability is bound to a normal keyboard slot. Details: ${JSON.stringify({ actionBindings: result.bindingDiagnostic.actionBindings, availableAbilities: result.bindingDiagnostic.availableAbilities.slice(0, 18), })}`); } result.mappedHealAvailable = Boolean(plan.healing); let snapshot = await readLiveState(page); let selected = await selectHostileWithTab(page, result, 10); if (selected) snapshot = selected.snapshot; let candidate = (selected?.target && hasFinitePosition(selected.target.position) ? selected.target : null) ?? liveMobs(snapshot) .map((mob) => ({ ...mob, distance: distance3(snapshot.player.position, mob.position) })) .filter((mob) => mob.distance < 120 && !mob.boss && hasFinitePosition(mob.position)) .sort((left, right) => left.distance - right.distance)[0] ?? null; if (!candidate) { const diagnostic = await readLiveState(page, true); fs.writeFileSync(path.join(outputDirectory, `${route.id}-target-diagnostic.json`), `${JSON.stringify({ targeting: result.targeting, sceneMobsAtEntry: result.sceneMobsAtEntry, player: diagnostic.player, mobs: diagnostic.mobs }, null, 2)}\n`); throw new Error(`${route.title}: no live in-scene pack was available near the campaign entrance.`); } assert(hasFinitePosition(candidate.position), `${route.title}: candidate mob transform must be finite before movement.`); result.firstPackTarget = { id: candidate.id, name: candidate.name, position: candidate.position, transformAvailable: hasFinitePosition(candidate.position), distanceAtSelection: distance3(snapshot.player.position, candidate.position), boss: candidate.boss }; const damageRange = Number(plan.damage.range?.max ?? 40); let walked = 0; let stuckSteps = 0; for (let step = 0; step < 18; step += 1) { const current = await readLiveState(page); let freshTarget = current.mobs.find((mob) => mob.id === candidate.id && !mob.dead && !mob.despawned && !mob.boss && hasFinitePosition(mob.position)); if (!freshTarget) { freshTarget = liveMobs(current).filter((mob) => !mob.boss && hasFinitePosition(mob.position)) .map((mob) => ({ ...mob, distance: distance3(current.player.position, mob.position) })) .filter((mob) => mob.distance < 120).sort((left, right) => left.distance - right.distance)[0]; } if (!freshTarget) break; candidate = freshTarget; const targetPosition = freshTarget.position; const dist = distance3(current.player.position, targetPosition); if (dist <= Math.max(7, damageRange - 3) && walked >= 6) break; const moved = await walkToward(page, result, targetPosition, 700); if (!moved) break; walked += moved.step.planarDisplacement; stuckSteps = moved.step.blocked ? stuckSteps + 1 : 0; snapshot = moved.snapshot; if (stuckSteps >= 3) break; } await page.screenshot({ path: path.join(outputDirectory, result.screenshots.walk) }); result.keyboardTravelDistance = walked; result.positionAfterTraversal = snapshot.player.position; result.distanceTravelledAtEntry = entryState.game.distanceTravelled; result.distanceTravelledAfterTraversal = snapshot.game.distanceTravelled; await page.keyboard.press('F1'); result.partyCommands = [{ at: Date.now(), key: 'F1', command: 'Attack', targetId: snapshot.player.selectedTargetId }]; await page.waitForTimeout(900); await page.keyboard.press('F2'); result.partyCommands.push({ at: Date.now(), key: 'F2', command: 'Defend', reason: 'hold the initial encounter near the healer' }); const targetAgain = await selectHostileWithTab(page, result, 6); if (targetAgain) { snapshot = targetAgain.snapshot; if (targetAgain.target.position) candidate = targetAgain.target; } const endAt = Date.now() + routeDurationMs; let lastDamageAt = 0; let lastHealAt = 0; let middleCaptured = false; let lastSampleAt = 0; let noTargetStreak = 0; while (Date.now() < endAt) { snapshot = await readLiveState(page); collectCombatEvents(snapshot); if (snapshot.player.health <= 0) break; if (lastSampleAt === 0 || snapshot.now - lastSampleAt >= 1_000) { result.samples.push({ at: snapshot.now, player: snapshot.player, party: snapshot.party, mobs: snapshot.mobs.map((mob) => ({ id: mob.id, name: mob.name, health: mob.health, maxHealth: mob.maxHealth, dead: mob.dead, engaged: mob.engaged, activeCast: mob.activeCast, position: mob.position })) }); lastSampleAt = snapshot.now; } const currentTarget = snapshot.mobs.find((mob) => mob.id === snapshot.player.selectedTargetId && !mob.dead && !mob.despawned && !mob.boss && hasFinitePosition(mob.position)); const wounded = snapshot.party.filter((member) => member.health > 0 && member.health < member.maxHealth - 1) .sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth); if (wounded.length && plan.healing && Date.now() - lastHealAt > 1_100) { let member = wounded[0]; const healRange = Number(plan.healing.range?.max ?? 25); const pursuit = { at: Date.now(), memberId: member.id, memberName: member.name, startPosition: snapshot.player.position, startAllyPosition: member.position, startDistance: hasFinitePosition(member.position) ? distance3(snapshot.player.position, member.position) : null, movement: [], outcome: 'position-unavailable' }; for (let step = 0; step < 6 && !snapshot.player.activeCast; step += 1) { const refreshed = await readLiveState(page); const freshMember = refreshed.party.find((entry) => entry.id === member.id); if (!freshMember || freshMember.health <= 0 || !hasFinitePosition(freshMember.position)) { snapshot = refreshed; pursuit.outcome = 'member-no-longer-live-or-position-unavailable'; break; } snapshot = refreshed; member = freshMember; const distance = distance3(snapshot.player.position, member.position); pursuit.lastAllyPosition = member.position; pursuit.lastDistance = distance; if (distance <= healRange - 1.5) { pursuit.outcome = 'within-heal-range'; break; } const moved = await walkToward(page, result, member.position, 600); if (!moved || moved.step.planarDisplacement < 0.08) { pursuit.outcome = 'movement-blocked-or-aligned'; break; } pursuit.movement.push(moved.step); snapshot = moved.snapshot; } snapshot = await readLiveState(page); member = snapshot.party.find((entry) => entry.id === member.id) ?? member; pursuit.endPosition = snapshot.player.position; pursuit.endAllyPosition = member.position ?? null; pursuit.endDistance = hasFinitePosition(member.position) ? distance3(snapshot.player.position, member.position) : null; if (pursuit.endDistance !== null && pursuit.endDistance <= healRange - 1.5) { pursuit.outcome = 'within-heal-range'; } if (pursuit.outcome !== 'within-heal-range') { result.healPursuits.push(pursuit); lastHealAt = Date.now(); await page.waitForTimeout(200); continue; } const targetBefore = snapshot.player.selectedTargetId; await selectPartyMember(page, member); const cast = await performCast(page, result, plan.healing, 'healing', member.id, `heal-lowest-wounded-active-NPC (${Math.round(member.health / member.maxHealth * 100)}% before cast)`); pursuit.castActionIndex = result.actions.length - 1; pursuit.outcome = cast.cast.actionResolved ? 'cast-effective-heal' : cast.cast.actionAccepted ? 'cast-accepted-without-observed-healing' : 'cast-not-accepted'; result.healPursuits.push(pursuit); lastHealAt = Date.now(); if (!middleCaptured) { await page.screenshot({ path: path.join(outputDirectory, result.screenshots.middle) }); middleCaptured = true; } result.actions[result.actions.length - 1].hostileTargetBeforeAllySelection = targetBefore; if (cast.cast.actionResolved) result.effectiveNativeHeals = (result.effectiveNativeHeals ?? 0) + 1; noTargetStreak = 0; continue; } let target = currentTarget; if (!target) { const selectedTarget = await selectHostileWithTab(page, result, 6); target = selectedTarget?.target ?? null; if (selectedTarget) snapshot = selectedTarget.snapshot; } if (!target) { noTargetStreak += 1; const available = liveMobs(snapshot).filter((mob) => !mob.boss).map((mob) => ({ ...mob, distance: distance3(snapshot.player.position, mob.position) })) .filter((mob) => mob.distance < 75 && hasFinitePosition(mob.position)) .sort((left, right) => left.distance - right.distance)[0]; if (available && noTargetStreak < 3) { const moved = await walkToward(page, result, available.position, 650); if (moved && moved.step.planarDisplacement > 0.1) { await selectHostileWithTab(page, result, 6); continue; } } await page.waitForTimeout(350); continue; } noTargetStreak = 0; const targetDistance = distance3(snapshot.player.position, target.position); const targetRange = Math.max(0, Number(plan.damage.range?.max ?? 40) - 1.5); if (targetDistance > targetRange || !target.position) { const destination = target.position ?? (target.id === candidate.id ? candidate.position : null); if (!destination) { result.targetingWithoutPosition ??= []; result.targetingWithoutPosition.push({ at: snapshot.now, id: target.id, name: target.name }); const cast = await performCast(page, result, plan.damage, 'damage', target.id, 'keyboard damage attempt after Tab selection where mob transform was not readable'); if (cast.cast.actionResolved) result.effectivePlayerDamageCasts = (result.effectivePlayerDamageCasts ?? 0) + 1; continue; } const moved = await walkToward(page, result, destination, 550); if (moved && moved.step.planarDisplacement > 0.08) continue; } if (Date.now() - lastDamageAt > 700) { const cast = await performCast(page, result, plan.damage, 'damage', target.id, 'player keyboard cast against the Tab-selected live campaign mob'); lastDamageAt = Date.now(); if (!middleCaptured) { await page.screenshot({ path: path.join(outputDirectory, result.screenshots.middle) }); middleCaptured = true; } if (cast.cast.actionResolved) result.effectivePlayerDamageCasts = (result.effectivePlayerDamageCasts ?? 0) + 1; } else { await page.waitForTimeout(250); } } const final = await readLiveState(page); collectCombatEvents(final); result.segmentEndedAt = Date.now(); result.endingPosition = final.player.position; result.endingDistanceTravelled = final.game.distanceTravelled; result.playerAtEndpoint = final.player; result.partyAtEndpoint = final.party; result.sceneMobsAtEndpoint = final.mobs; const playerEvents = result.observedCombatEvents.filter(sourcePlayerEvent); const healedPartyIds = new Set(entryState.party.map((member) => member.id)); const effectiveHealEvents = playerEvents.filter((event) => event.kind === 'healing' && healedPartyIds.has(event.targetActorId) && event.effectiveAmount > 0); const effectiveDamageEvents = playerEvents.filter((event) => event.kind === 'damage' && event.effectiveAmount > 0); for (let index = 0; index < result.actions.length; index += 1) { const action = result.actions[index]; const nextSameActionAt = result.actions.slice(index + 1) .find((next) => next.abilityId === action.abilityId)?.at ?? Number.POSITIVE_INFINITY; const actionEvents = playerEvents.filter((event) => event.abilityId === action.abilityId && event.targetActorId === action.intendedTargetId && event.occurredAt >= action.at - 20 && event.occurredAt < nextSameActionAt); const effectEvents = actionEvents.filter((event) => event.kind === action.kind); action.resolvedEvents = effectEvents.map((event) => ({ kind: event.kind, targetActorId: event.targetActorId, effectiveAmount: event.effectiveAmount, overheal: event.overheal, school: event.school ?? null, abilityId: event.abilityId, occurredAt: event.occurredAt })); action.actionAccepted = actionEvents.some((event) => event.kind === 'cast' && ['started', 'completed'].includes(event.outcome)); action.actionResolved = effectEvents.some((event) => event.effectiveAmount > 0); } result.effectiveNativeHeals = effectiveHealEvents.length; result.totalEffectiveNativeHealing = effectiveHealEvents.reduce((sum, event) => sum + event.effectiveAmount, 0); result.effectivePlayerDamageEvents = effectiveDamageEvents.length; result.totalEffectivePlayerDamage = effectiveDamageEvents.reduce((sum, event) => sum + event.effectiveAmount, 0); result.coverageEndpoint = { endpoint: final.game.areaName, endpointPosition: final.player.position, accumulatedTravelDistance: final.game.distanceTravelled - entryState.game.distanceTravelled, keyboardStepDisplacement: result.movement.reduce((sum, step) => sum + step.planarDisplacement, 0), totalKeyboardKeyDowns: result.movement.length, damageEvents: result.effectivePlayerDamageEvents, totalEffectivePlayerDamage: result.totalEffectivePlayerDamage, healingEvents: effectiveHealEvents.length, totalEffectiveNativeHealing: result.totalEffectiveNativeHealing, observedSchools: [...new Set(result.observedCombatEvents.map((event) => event.school).filter(Boolean))].sort(), eventCountsByKind: Object.fromEntries([...new Set(result.observedCombatEvents.map((event) => event.kind))] .map((kind) => [kind, result.observedCombatEvents.filter((event) => event.kind === kind).length])), killedMobNames: final.mobs.filter((mob) => mob.dead).map((mob) => mob.name), observedDeadMobNames: [...new Set(observedDeadMobs.values())].sort(), observedDeadMobCount: observedDeadMobs.size, partyDeaths: final.party.filter((member) => member.health <= 0).map((member) => member.name), }; await page.screenshot({ path: path.join(outputDirectory, result.screenshots.endpoint) }); result.pageErrors = []; fs.writeFileSync(path.join(outputDirectory, `${route.id}-route.json`), `${JSON.stringify(result, null, 2)}\n`); return result; } (async () => { const { createGameServer } = await import('../server/server.mjs'); const runtime = createGameServer({ dataDir: dataDirectory, enableShelvedModes: false }); await new Promise((resolve, reject) => { runtime.server.once('error', reject); runtime.server.listen(0, '127.0.0.1', resolve); }); const address = runtime.server.address(); const baseUrl = `http://127.0.0.1:${address.port}`; const browser = await chromium.launch({ channel: 'msedge', headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); page.setDefaultTimeout(120_000); const pageErrors = []; const consoleErrors = []; const completedRoutes = []; page.on('pageerror', (error) => pageErrors.push(error.message)); page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() }); }); await page.addInitScript(({ urls }) => { window.nativeRouteKeyLog = []; window.addEventListener('keydown', (event) => { if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'Tab', 'F1', 'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Escape'].includes(event.code)) { window.nativeRouteKeyLog.push({ at: Date.now(), code: event.code, repeat: event.repeat }); } }, true); window.nativeRouteStores = async () => { const modules = await Promise.all(urls.map((url) => import(url))); const namedExports = modules.flatMap((module) => Object.entries(module)); const exports = namedExports.map(([, value]) => value); const stores = exports.filter((value) => value && typeof value.getState === 'function'); // Vite renames this exported registry function. Match the inspected pure // getMobRuntimeTransform body: it only reads the private Map and copies // position/yaw, returning null for an unmounted actor. const transformExport = namedExports.find(([, value]) => { if (typeof value !== 'function') return false; const source = Function.prototype.toString.call(value); return source.includes('.mounted') && source.includes('.value') && source.includes('position:') && source.includes('yaw:') && source.includes('...') && source.includes('null'); }); const readTransform = transformExport?.[1] ?? null; // Source-mapped to getPartyRuntimePosition: it reads the party position // registry and returns a defensive copy or null, with no state mutation. const partyPositionExport = namedExports.find(([, value]) => { if (typeof value !== 'function') return false; const source = Function.prototype.toString.call(value); return /\.get\(/.test(source) && /return\s+[A-Za-z_$][\w$]*\s*\?\s*\[\s*\.\.\.\s*[A-Za-z_$][\w$]*\s*\]\s*:\s*null/.test(source); }); const readPartyPosition = partyPositionExport?.[1] ?? null; return { getMobPosition: readTransform ? (id) => readTransform(id)?.position ?? null : null, mobPositionExports: transformExport ? [transformExport[0]] : [], getPartyPosition: readPartyPosition, partyPositionExports: partyPositionExport ? [partyPositionExport[0]] : [], game: stores.find((store) => typeof store.getState()?.relocatePlayer === 'function'), shell: stores.find((store) => typeof store.getState()?.saveActiveProgression === 'function'), combat: stores.find((store) => typeof store.getState()?.castAbility === 'function'), party: stores.find((store) => typeof store.getState()?.setCommand === 'function' && Array.isArray(store.getState()?.members)), }; }; }, { urls: moduleUrls }); try { if (moduleFiles.length < 3) throw new Error(`Expected built game assets were not found in dist/assets: ${moduleFiles.join(', ')}`); await page.goto(baseUrl, { waitUntil: 'domcontentloaded' }); const { profile, rosterUi } = await prepareCharacter(page); for (let index = 0; index < routesToRun.length; index += 1) { const route = routesToRun[index]; await enterCampaignDungeon(page, route, index === 0, profile.level); await page.evaluate(() => { window.nativeRouteStoresRole = 'healer'; }); const entry = await readLiveState(page, true); const routeResult = await runRouteSegment(page, route, entry, profile); routeResult.pageErrors = pageErrors.slice(); routeResult.consoleErrors = consoleErrors.slice(); routeResult.keyboardEvents = await page.evaluate(() => window.nativeRouteKeyLog.slice(-500)); fs.writeFileSync(path.join(outputDirectory, `${route.id}-route.json`), `${JSON.stringify(routeResult, null, 2)}\n`); completedRoutes.push(routeResult); } assert.deepEqual(pageErrors, [], `No browser page exceptions: ${JSON.stringify(pageErrors)}`); fs.writeFileSync(path.join(outputDirectory, 'results.json'), `${JSON.stringify({ status: 'complete', snapshotKind: baseline ? 'baseline-existing-dist; final-build rerun required' : 'final-build route QA', buildAssets: moduleFiles, routeDurationMs, profile, rosterUi, routes: completedRoutes.map((route) => ({ dungeonId: route.dungeonId, coverage: route.coverage, coverageEndpoint: route.coverageEndpoint, mappedHealAvailable: route.mappedHealAvailable, effectiveNativeHeals: route.effectiveNativeHeals ?? 0, totalEffectiveNativeHealing: route.totalEffectiveNativeHealing ?? 0, effectivePlayerDamageEvents: route.effectivePlayerDamageEvents ?? 0, totalEffectivePlayerDamage: route.totalEffectivePlayerDamage ?? 0, coverageEndpoint: route.coverageEndpoint, })), pageErrors, consoleErrors, }, null, 2)}\n`); console.log(`Native campaign route QA ${baseline ? 'baseline' : 'complete'}: ${outputDirectory}`); } catch (error) { await page.screenshot({ path: path.join(outputDirectory, 'failure.png') }).catch(() => {}); const failed = { status: 'failed', snapshotKind: baseline ? 'baseline-existing-dist' : 'final-build route QA', buildAssets: moduleFiles, error: error.stack ?? error.message, pageErrors, consoleErrors, bodyText: await page.locator('body').innerText().catch(() => '') }; fs.writeFileSync(path.join(outputDirectory, 'failure.json'), `${JSON.stringify(failed, null, 2)}\n`); console.error(failed.bodyText.slice(-2_500)); throw error; } finally { await browser.close(); await new Promise((resolve) => runtime.server.close(resolve)); const target = fs.realpathSync(dataDirectory); if (path.dirname(target) !== fs.realpathSync(os.tmpdir()) || !path.basename(target).startsWith('healer-man-native-routes-')) { throw new Error('Unsafe temporary playtest database cleanup path.'); } fs.rmSync(target, { recursive: true, force: true }); } })().catch((error) => { console.error(error); process.exitCode = 1; });