856 lines
26 KiB
JavaScript
856 lines
26 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { mkdirSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { chromium } from 'playwright';
|
|
import {
|
|
desktopBrowserContextOptions,
|
|
desktopBrowserDeviceScaleFactor,
|
|
desktopBrowserViewport,
|
|
desktopBrowserViewportBounds
|
|
} from './desktop-browser-viewport.mjs';
|
|
|
|
const battleId = 'second-battle-yellow-turban-pursuit';
|
|
const renderer = process.env.VERIFY_TURN_END_RISK_RENDERER;
|
|
const rendererNames = ['canvas', 'webgl'];
|
|
|
|
if (!renderer) {
|
|
for (const requestedRenderer of rendererNames) {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[fileURLToPath(import.meta.url)],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
VERIFY_TURN_END_RISK_RENDERER: requestedRenderer
|
|
},
|
|
stdio: 'inherit'
|
|
}
|
|
);
|
|
if (result.status !== 0) {
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
assert(
|
|
rendererNames.includes(renderer),
|
|
`Unsupported turn-end risk renderer "${renderer}".`
|
|
);
|
|
|
|
const baseUrl =
|
|
process.env.VERIFY_TURN_END_RISK_URL ??
|
|
'http://127.0.0.1:41811/heros_web/';
|
|
const targetUrl = withDebugOptions(baseUrl, renderer);
|
|
const screenshotPath =
|
|
`dist/verification-turn-end-risk-${renderer}-1920x1080.png`;
|
|
const forcedAttacks = Object.freeze([
|
|
{ targetIndex: 0, damage: 16, hitRate: 60, criticalRate: 0 },
|
|
{ targetIndex: 0, damage: 24, hitRate: 90, criticalRate: 20 },
|
|
{ targetIndex: 1, damage: 10, hitRate: 75, criticalRate: 0 },
|
|
{ targetIndex: 2, damage: 8, hitRate: 50, criticalRate: 0 }
|
|
]);
|
|
|
|
let serverProcess;
|
|
let browser;
|
|
|
|
try {
|
|
mkdirSync('dist', { recursive: true });
|
|
serverProcess = await ensureLocalServer(targetUrl);
|
|
browser = await chromium.launch({
|
|
headless: process.env.VERIFY_TURN_END_RISK_HEADLESS !== '0',
|
|
args: renderer === 'webgl'
|
|
? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']
|
|
: []
|
|
});
|
|
const context = await browser.newContext(desktopBrowserContextOptions);
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(30000);
|
|
|
|
const pageErrors = [];
|
|
const consoleErrors = [];
|
|
page.on('pageerror', (error) =>
|
|
pageErrors.push(error.stack ?? error.message)
|
|
);
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
consoleErrors.push(message.text());
|
|
}
|
|
});
|
|
|
|
await page.addInitScript(() => window.localStorage.clear());
|
|
await page.goto(targetUrl, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForBattleReady(page);
|
|
|
|
const runtime = await readRuntimeProbe(page);
|
|
assertRuntimeBaseline(runtime, renderer);
|
|
await startDeploymentIfNeeded(page);
|
|
|
|
const setup = await installTurnEndRiskFixture(page);
|
|
assert.equal(setup.allyIds.length >= 3, true);
|
|
assert.equal(setup.forcedAttackCount, 4);
|
|
assert.equal(setup.forcedThreatTargetCount, 3);
|
|
assert.deepEqual(
|
|
setup.expectedVisibleGroupIds,
|
|
setup.expectedGroups.slice(0, 2).map((group) => group.targetId)
|
|
);
|
|
|
|
const prompt = await openManualTurnEndPrompt(page);
|
|
assertRiskForecast(prompt, setup, renderer);
|
|
assertPromptLayout(prompt, renderer);
|
|
|
|
const screenshot = await page.screenshot({
|
|
path: screenshotPath,
|
|
fullPage: true
|
|
});
|
|
assert(
|
|
screenshot.byteLength >= 100_000,
|
|
`Expected a painted ${renderer} turn-end risk screenshot: ${screenshot.byteLength} bytes.`
|
|
);
|
|
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForFunction(() => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.turnPromptVisible === false &&
|
|
battle.activeFaction === 'ally'
|
|
);
|
|
});
|
|
const safeReturn = await page.evaluate(() =>
|
|
window.__HEROS_DEBUG__?.battle()
|
|
);
|
|
assert.equal(
|
|
safeReturn?.actedUnitIds?.length,
|
|
0,
|
|
`${renderer}: safe default must return to command without abandoning unacted allies.`
|
|
);
|
|
|
|
assert.deepEqual(
|
|
pageErrors,
|
|
[],
|
|
`${renderer}: unexpected page errors: ${pageErrors.join(' | ')}`
|
|
);
|
|
assert.deepEqual(
|
|
consoleErrors,
|
|
[],
|
|
`${renderer}: unexpected console errors: ${consoleErrors.join(' | ')}`
|
|
);
|
|
|
|
console.log(
|
|
`Verified ${renderer} early turn-end risk forecast at ` +
|
|
`${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
|
|
`100% zoom, DPR ${desktopBrowserDeviceScaleFactor}; captured ${screenshotPath}.`
|
|
);
|
|
} finally {
|
|
await browser?.close();
|
|
await stopServerProcess(serverProcess);
|
|
}
|
|
|
|
function withDebugOptions(url, requestedRenderer) {
|
|
const parsed = new URL(url);
|
|
parsed.searchParams.set('debug', '1');
|
|
parsed.searchParams.set('renderer', requestedRenderer);
|
|
parsed.searchParams.set('debugBattle', battleId);
|
|
parsed.searchParams.set('debugCombatAssetWatchdogMs', '250');
|
|
return parsed.toString();
|
|
}
|
|
|
|
async function waitForBattleReady(page) {
|
|
await page.waitForFunction(
|
|
(expectedBattleId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle?.();
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes?.() ?? [];
|
|
return (
|
|
window.__HEROS_GAME__ !== undefined &&
|
|
battle?.battleId === expectedBattleId &&
|
|
['deployment', 'idle'].includes(battle.phase) &&
|
|
battle.mapBackgroundReady === true &&
|
|
(
|
|
battle.phase === 'idle' ||
|
|
['ready', 'degraded'].includes(battle.combatAssets?.status)
|
|
) &&
|
|
activeScenes.includes('BattleScene')
|
|
);
|
|
},
|
|
battleId,
|
|
{ timeout: 90000 }
|
|
);
|
|
await page.evaluate(() => new Promise((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
|
}));
|
|
}
|
|
|
|
async function startDeploymentIfNeeded(page) {
|
|
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
|
if (state?.phase === 'idle') {
|
|
return;
|
|
}
|
|
const startButtonBounds =
|
|
state?.battleHud?.deploymentPanel?.startButtonBounds;
|
|
assert(
|
|
state?.battleId === battleId &&
|
|
state.phase === 'deployment' &&
|
|
startButtonBounds,
|
|
`Expected ${battleId} deployment controls: ${JSON.stringify(state)}`
|
|
);
|
|
await clickSceneBounds(page, startButtonBounds);
|
|
await page.waitForFunction(
|
|
(expectedBattleId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.battleId === expectedBattleId &&
|
|
battle.phase === 'idle' &&
|
|
battle.battleHud?.deploymentPanel === null
|
|
);
|
|
},
|
|
battleId,
|
|
{ timeout: 90000 }
|
|
);
|
|
}
|
|
|
|
async function installTurnEndRiskFixture(page) {
|
|
return page.evaluate((attackDefinitions) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const units = scene?.debugBattleUnits?.() ?? [];
|
|
const allies = units.filter(
|
|
(unit) => unit.faction === 'ally' && unit.hp > 0
|
|
);
|
|
const enemies = units.filter(
|
|
(unit) => unit.faction === 'enemy' && unit.hp > 0
|
|
);
|
|
if (!scene || allies.length < 3 || enemies.length < attackDefinitions.length) {
|
|
return {
|
|
allyIds: [],
|
|
forcedAttackCount: 0,
|
|
forcedThreatTargetCount: 0,
|
|
expectedGroups: [],
|
|
expectedVisibleGroupIds: [],
|
|
expectedDamage: 0,
|
|
expectedLethalTargetCount: 0
|
|
};
|
|
}
|
|
|
|
scene.hideBattleEventBanner();
|
|
scene.hideTurnEndPrompt();
|
|
scene.hideSaveSlotPanel();
|
|
scene.hideMapMenu();
|
|
scene.hideCommandMenu();
|
|
scene.clearMarkers();
|
|
scene.activeFaction = 'ally';
|
|
scene.phase = 'idle';
|
|
scene.selectedUnit = undefined;
|
|
scene.pendingMove = undefined;
|
|
scene.targetingAction = undefined;
|
|
scene.selectedUsable = undefined;
|
|
scene.actedUnitIds.clear();
|
|
scene.clearEnemyIntentForecast();
|
|
|
|
const attacks = attackDefinitions.map((definition, index) => ({
|
|
...definition,
|
|
target: allies[definition.targetIndex],
|
|
enemy: enemies[index]
|
|
}));
|
|
attacks.forEach((attack) => {
|
|
const plan = scene.buildEnemyActionPlan(attack.enemy);
|
|
scene.enemyIntentForecasts.set(attack.enemy.id, {
|
|
...plan,
|
|
kind: 'attack',
|
|
target: attack.target,
|
|
approachTarget: attack.target,
|
|
usable: undefined,
|
|
destination: { x: attack.enemy.x, y: attack.enemy.y },
|
|
preview: {
|
|
...(plan.preview ?? {}),
|
|
damage: attack.damage,
|
|
hitRate: attack.hitRate,
|
|
criticalRate: attack.criticalRate
|
|
}
|
|
});
|
|
});
|
|
scene.renderSituationPanel('조기 턴 종료 위험 요약 검증');
|
|
|
|
const expectedGroups = allies.slice(0, 3).map((target) => {
|
|
const targeted = attacks.filter(
|
|
(attack) => attack.target.id === target.id
|
|
);
|
|
const expectedDamage = Math.round(targeted.reduce(
|
|
(total, attack) => total +
|
|
attack.damage *
|
|
attack.hitRate /
|
|
100 *
|
|
(1 + attack.criticalRate / 200),
|
|
0
|
|
));
|
|
const rawDamage = targeted.reduce(
|
|
(total, attack) => total + attack.damage,
|
|
0
|
|
);
|
|
const maximumDamage = targeted.reduce(
|
|
(total, attack) => total + (
|
|
attack.criticalRate > 0
|
|
? Math.round(attack.damage * 1.5)
|
|
: attack.damage
|
|
),
|
|
0
|
|
);
|
|
return {
|
|
targetId: target.id,
|
|
targetName: target.name,
|
|
hp: target.hp,
|
|
immediateCount: targeted.length,
|
|
approachCount: 0,
|
|
expectedDamage,
|
|
rawDamage,
|
|
maximumDamage,
|
|
lethal: maximumDamage >= target.hp,
|
|
hitRateMin: Math.min(
|
|
...targeted.map((attack) => attack.hitRate)
|
|
),
|
|
hitRateMax: Math.max(
|
|
...targeted.map((attack) => attack.hitRate)
|
|
),
|
|
concentrated: targeted.length > 1,
|
|
attackCount: targeted.length
|
|
};
|
|
});
|
|
|
|
return {
|
|
allyIds: allies.map((unit) => unit.id),
|
|
allyNames: allies.map((unit) => unit.name),
|
|
forcedAttackCount: attacks.length,
|
|
forcedThreatTargetCount: new Set(
|
|
attacks.map((attack) => attack.target.id)
|
|
).size,
|
|
expectedGroups,
|
|
expectedVisibleGroupIds:
|
|
expectedGroups.slice(0, 2).map((group) => group.targetId),
|
|
expectedDamage: expectedGroups.reduce(
|
|
(total, group) => total + group.expectedDamage,
|
|
0
|
|
),
|
|
expectedLethalTargetCount:
|
|
expectedGroups.filter((group) => group.lethal).length
|
|
};
|
|
}, forcedAttacks);
|
|
}
|
|
|
|
async function openManualTurnEndPrompt(page) {
|
|
const pointer = await findRightmostEmptyBattleTile(page);
|
|
await page.mouse.click(pointer.x, pointer.y, { button: 'right' });
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.mapMenuVisible === true
|
|
);
|
|
const mapMenu = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
return scene?.mapMenuLayout
|
|
? { ...scene.mapMenuLayout, actions: [...scene.mapMenuLayout.actions] }
|
|
: null;
|
|
});
|
|
const endTurnIndex = mapMenu?.actions.indexOf('endTurn') ?? -1;
|
|
assert(
|
|
mapMenu && endTurnIndex >= 0,
|
|
`Expected an enabled manual turn-end action: ${JSON.stringify(mapMenu)}`
|
|
);
|
|
const keyboardState = await page.evaluate(
|
|
() => window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard
|
|
);
|
|
assert.equal(
|
|
keyboardState?.focusedItem?.action,
|
|
'endTurn',
|
|
`Expected turn end to be the map menu's safe keyboard default: ${JSON.stringify(keyboardState)}`
|
|
);
|
|
assert.equal(keyboardState?.focusedItem?.available, true);
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === true
|
|
);
|
|
return page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt);
|
|
}
|
|
|
|
function assertRiskForecast(prompt, setup, requestedRenderer) {
|
|
const forecast = prompt?.riskForecast;
|
|
const primary = prompt?.buttons?.find((button) => button.id === 'primary');
|
|
const secondary =
|
|
prompt?.buttons?.find((button) => button.id === 'secondary');
|
|
|
|
assert.equal(
|
|
prompt?.title,
|
|
`미행동 ${setup.allyIds.length}명 · 턴 종료 확인`,
|
|
`${requestedRenderer}: early turn-end title must disclose every unacted ally.`
|
|
);
|
|
setup.allyNames.forEach((name) => {
|
|
assert(
|
|
prompt.body.includes(name),
|
|
`${requestedRenderer}: missing unacted ally ${name}: ${prompt.body}`
|
|
);
|
|
});
|
|
assert.equal(forecast?.immediateAttackCount, setup.forcedAttackCount);
|
|
assert.equal(forecast?.approachCount, 0);
|
|
assert.equal(forecast?.targetCount, setup.forcedThreatTargetCount);
|
|
assert.equal(forecast?.expectedDamage, setup.expectedDamage);
|
|
assert.equal(
|
|
forecast?.lethalTargetCount,
|
|
setup.expectedLethalTargetCount
|
|
);
|
|
assert.equal(forecast?.omittedTargetCount, 1);
|
|
assert.deepEqual(
|
|
forecast?.visibleGroupIds,
|
|
setup.expectedVisibleGroupIds
|
|
);
|
|
assert.equal(forecast?.groups?.length, setup.expectedGroups.length);
|
|
|
|
setup.expectedGroups.forEach((expected) => {
|
|
const actual = forecast.groups.find(
|
|
(group) => group.targetId === expected.targetId
|
|
);
|
|
const row = prompt.riskRows?.find(
|
|
(candidate) => candidate.targetId === expected.targetId
|
|
);
|
|
assert(actual, `${requestedRenderer}: missing risk group ${expected.targetId}.`);
|
|
assert.deepEqual(
|
|
{
|
|
targetId: actual.targetId,
|
|
targetName: actual.targetName,
|
|
hp: actual.hp,
|
|
immediateCount: actual.immediateCount,
|
|
approachCount: actual.approachCount,
|
|
expectedDamage: actual.expectedDamage,
|
|
rawDamage: actual.rawDamage,
|
|
maximumDamage: actual.maximumDamage,
|
|
lethal: actual.lethal,
|
|
hitRateMin: actual.hitRateMin,
|
|
hitRateMax: actual.hitRateMax,
|
|
concentrated: actual.concentrated,
|
|
attackCount: actual.attacks?.length
|
|
},
|
|
expected,
|
|
`${requestedRenderer}: risk group mismatch for ${expected.targetName}.`
|
|
);
|
|
if (forecast.visibleGroupIds.includes(expected.targetId)) {
|
|
const expectedBadge = expected.concentrated && expected.lethal
|
|
? '집중 공격 · 격파 가능'
|
|
: expected.lethal
|
|
? '격파 가능'
|
|
: expected.concentrated
|
|
? '집중 공격'
|
|
: expected.immediateCount > 0
|
|
? '피격 예상'
|
|
: '접근 예정';
|
|
assert(row, `${requestedRenderer}: missing visible risk row ${expected.targetId}.`);
|
|
assert.equal(row.badgeText, expectedBadge);
|
|
assert.equal(
|
|
row.titleText,
|
|
`${expected.targetName} · 현재 HP ${expected.hp}`
|
|
);
|
|
assert(
|
|
!row.titleText.includes('기대 후'),
|
|
`${requestedRenderer}: risk row must not imply a deterministic post-turn HP.`
|
|
);
|
|
assert(
|
|
row.metricText.includes(`공격 ${expected.immediateCount}회`) &&
|
|
row.metricText.includes(`기대 피해 ${expected.expectedDamage}`) &&
|
|
row.metricText.includes(`최대 피해 ${expected.maximumDamage}`),
|
|
`${requestedRenderer}: incomplete risk metrics for ${expected.targetName}: ${row.metricText}`
|
|
);
|
|
}
|
|
});
|
|
|
|
assert.deepEqual(
|
|
{
|
|
focusedAction: prompt.focusedAction,
|
|
cancelAction: prompt.cancelAction,
|
|
primary: {
|
|
label: primary?.label,
|
|
meaning: primary?.meaning,
|
|
dangerous: primary?.dangerous,
|
|
focused: primary?.focused,
|
|
escapeCancels: primary?.escapeCancels
|
|
},
|
|
secondary: {
|
|
label: secondary?.label,
|
|
meaning: secondary?.meaning,
|
|
dangerous: secondary?.dangerous,
|
|
focused: secondary?.focused,
|
|
escapeCancels: secondary?.escapeCancels
|
|
}
|
|
},
|
|
{
|
|
focusedAction: 'primary',
|
|
cancelAction: 'primary',
|
|
primary: {
|
|
label: '계속 지휘',
|
|
meaning: 'continue-command',
|
|
dangerous: false,
|
|
focused: true,
|
|
escapeCancels: true
|
|
},
|
|
secondary: {
|
|
label: '미행동 포기 후 종료',
|
|
meaning: 'abandon-unacted-and-end',
|
|
dangerous: true,
|
|
focused: false,
|
|
escapeCancels: false
|
|
}
|
|
},
|
|
`${requestedRenderer}: safe focus or explicit danger semantics regressed.`
|
|
);
|
|
}
|
|
|
|
function assertPromptLayout(prompt, requestedRenderer) {
|
|
const panel = assertFiniteBounds(
|
|
prompt?.panelBounds,
|
|
`${requestedRenderer} prompt panel`
|
|
);
|
|
const summary = assertFiniteBounds(
|
|
prompt?.summaryBounds,
|
|
`${requestedRenderer} prompt summary`
|
|
);
|
|
const footnote = assertFiniteBounds(
|
|
prompt?.footnoteBounds,
|
|
`${requestedRenderer} prompt footnote`
|
|
);
|
|
const rows = prompt?.riskRows ?? [];
|
|
const buttons = prompt?.buttons ?? [];
|
|
|
|
assert.equal(
|
|
rows.length,
|
|
prompt?.riskForecast?.visibleGroupIds?.length,
|
|
`${requestedRenderer}: every visible risk group must have one row.`
|
|
);
|
|
assert.deepEqual(
|
|
rows.map((row) => row.targetId),
|
|
prompt.riskForecast.visibleGroupIds,
|
|
`${requestedRenderer}: row order must match visible risk priority.`
|
|
);
|
|
assert.equal(buttons.length, 2, `${requestedRenderer}: expected two prompt buttons.`);
|
|
|
|
const rowBounds = rows.map((row) => {
|
|
const bounds = assertFiniteBounds(
|
|
row.bounds,
|
|
`${requestedRenderer} risk row ${row.targetId}`
|
|
);
|
|
const badgeBounds = assertFiniteBounds(
|
|
row.badgeBounds,
|
|
`${requestedRenderer} risk badge ${row.targetId}`
|
|
);
|
|
const titleBounds = assertFiniteBounds(
|
|
row.titleBounds,
|
|
`${requestedRenderer} risk title ${row.targetId}`
|
|
);
|
|
const metricBounds = assertFiniteBounds(
|
|
row.metricBounds,
|
|
`${requestedRenderer} risk metrics ${row.targetId}`
|
|
);
|
|
[badgeBounds, titleBounds, metricBounds].forEach((textBounds, index) => {
|
|
assertBoundsContained(
|
|
textBounds,
|
|
bounds,
|
|
`${requestedRenderer} risk row ${row.targetId} text ${index + 1}`
|
|
);
|
|
});
|
|
assert(
|
|
!boundsOverlap(titleBounds, badgeBounds),
|
|
`${requestedRenderer}: title and warning badge overlap for ${row.targetId}.`
|
|
);
|
|
assert(
|
|
!boundsOverlap(metricBounds, badgeBounds),
|
|
`${requestedRenderer}: metrics and warning badge overlap for ${row.targetId}.`
|
|
);
|
|
assert(
|
|
!boundsOverlap(titleBounds, metricBounds),
|
|
`${requestedRenderer}: title and metrics overlap for ${row.targetId}.`
|
|
);
|
|
return bounds;
|
|
});
|
|
const buttonBounds = buttons.map((button) =>
|
|
assertFiniteBounds(
|
|
button.bounds,
|
|
`${requestedRenderer} prompt button ${button.id}`
|
|
)
|
|
);
|
|
const contentBounds = [summary, ...rowBounds, footnote, ...buttonBounds];
|
|
|
|
assertBoundsContained(
|
|
panel,
|
|
desktopBrowserViewportBounds,
|
|
`${requestedRenderer} prompt panel in viewport`
|
|
);
|
|
contentBounds.forEach((bounds, index) => {
|
|
assertBoundsContained(
|
|
bounds,
|
|
panel,
|
|
`${requestedRenderer} prompt content ${index + 1} in panel`
|
|
);
|
|
assertBoundsContained(
|
|
bounds,
|
|
desktopBrowserViewportBounds,
|
|
`${requestedRenderer} prompt content ${index + 1} in viewport`
|
|
);
|
|
});
|
|
|
|
for (let leftIndex = 0; leftIndex < contentBounds.length; leftIndex += 1) {
|
|
for (
|
|
let rightIndex = leftIndex + 1;
|
|
rightIndex < contentBounds.length;
|
|
rightIndex += 1
|
|
) {
|
|
assert(
|
|
!boundsOverlap(
|
|
contentBounds[leftIndex],
|
|
contentBounds[rightIndex]
|
|
),
|
|
`${requestedRenderer}: prompt regions ${leftIndex + 1} and ` +
|
|
`${rightIndex + 1} overlap: ${JSON.stringify({
|
|
left: contentBounds[leftIndex],
|
|
right: contentBounds[rightIndex]
|
|
})}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function readRuntimeProbe(page) {
|
|
return page.evaluate(() => {
|
|
const game = window.__HEROS_GAME__;
|
|
const scene = game?.scene.getScene('BattleScene');
|
|
const canvas = document.querySelector('canvas');
|
|
const canvasBounds = canvas?.getBoundingClientRect();
|
|
return {
|
|
viewport: {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
dpr: window.devicePixelRatio,
|
|
zoom: window.visualViewport?.scale ?? 1
|
|
},
|
|
canvas: canvas
|
|
? {
|
|
width: canvas.width,
|
|
height: canvas.height,
|
|
bounds: canvasBounds
|
|
? {
|
|
x: canvasBounds.x,
|
|
y: canvasBounds.y,
|
|
width: canvasBounds.width,
|
|
height: canvasBounds.height
|
|
}
|
|
: null
|
|
}
|
|
: null,
|
|
renderer: {
|
|
requested: new URLSearchParams(window.location.search).get(
|
|
'renderer'
|
|
),
|
|
type: game?.renderer?.type ?? null,
|
|
name: game?.renderer?.constructor?.name ?? null
|
|
},
|
|
scene: scene
|
|
? { width: scene.scale.width, height: scene.scale.height }
|
|
: null
|
|
};
|
|
});
|
|
}
|
|
|
|
function assertRuntimeBaseline(runtime, requestedRenderer) {
|
|
assert.deepEqual(runtime.viewport, {
|
|
width: desktopBrowserViewport.width,
|
|
height: desktopBrowserViewport.height,
|
|
dpr: desktopBrowserDeviceScaleFactor,
|
|
zoom: 1
|
|
});
|
|
assert.deepEqual(runtime.canvas, {
|
|
width: desktopBrowserViewport.width,
|
|
height: desktopBrowserViewport.height,
|
|
bounds: {
|
|
x: 0,
|
|
y: 0,
|
|
width: desktopBrowserViewport.width,
|
|
height: desktopBrowserViewport.height
|
|
}
|
|
});
|
|
assert.deepEqual(runtime.scene, {
|
|
width: desktopBrowserViewport.width,
|
|
height: desktopBrowserViewport.height
|
|
});
|
|
assert.equal(runtime.renderer.requested, requestedRenderer);
|
|
assert.equal(
|
|
runtime.renderer.type,
|
|
requestedRenderer === 'canvas' ? 1 : 2,
|
|
`Expected ${requestedRenderer} renderer: ${JSON.stringify(runtime.renderer)}`
|
|
);
|
|
}
|
|
|
|
async function findRightmostEmptyBattleTile(page) {
|
|
const logicalPoint = await page.evaluate(() => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
if (!scene) {
|
|
return null;
|
|
}
|
|
for (
|
|
let column = scene.layout.visibleColumns - 1;
|
|
column >= 0;
|
|
column -= 1
|
|
) {
|
|
for (let row = 0; row < scene.layout.visibleRows; row += 1) {
|
|
const x = scene.cameraTileX + column;
|
|
const y = scene.cameraTileY + row;
|
|
if (scene.isInBounds(x, y) && !scene.isOccupied(x, y)) {
|
|
return {
|
|
x: scene.tileCenterX(x),
|
|
y: scene.tileCenterY(y)
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
});
|
|
assert(logicalPoint, 'Expected a visible empty battlefield tile.');
|
|
return sceneLogicalPoint(page, logicalPoint);
|
|
}
|
|
|
|
async function clickSceneBounds(page, bounds) {
|
|
const point = await sceneLogicalPoint(page, {
|
|
x: bounds.x + bounds.width / 2,
|
|
y: bounds.y + bounds.height / 2
|
|
});
|
|
await page.mouse.click(point.x, point.y);
|
|
}
|
|
|
|
async function sceneLogicalPoint(page, logicalPoint) {
|
|
const point = await page.evaluate((requestedPoint) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const canvasBounds =
|
|
document.querySelector('canvas')?.getBoundingClientRect();
|
|
if (!scene || !canvasBounds || !requestedPoint) {
|
|
return null;
|
|
}
|
|
return {
|
|
x:
|
|
canvasBounds.left +
|
|
requestedPoint.x * canvasBounds.width / scene.scale.width,
|
|
y:
|
|
canvasBounds.top +
|
|
requestedPoint.y * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, logicalPoint);
|
|
assert(
|
|
point && Number.isFinite(point.x) && Number.isFinite(point.y),
|
|
`Expected a finite BattleScene point: ${JSON.stringify({
|
|
logicalPoint,
|
|
point
|
|
})}`
|
|
);
|
|
return point;
|
|
}
|
|
|
|
function assertFiniteBounds(bounds, label) {
|
|
assert(
|
|
bounds &&
|
|
Number.isFinite(bounds.x) &&
|
|
Number.isFinite(bounds.y) &&
|
|
Number.isFinite(bounds.width) &&
|
|
Number.isFinite(bounds.height) &&
|
|
bounds.width > 0 &&
|
|
bounds.height > 0,
|
|
`Expected finite positive bounds for ${label}: ${JSON.stringify(bounds)}`
|
|
);
|
|
return bounds;
|
|
}
|
|
|
|
function assertBoundsContained(inner, outer, label) {
|
|
const tolerance = 1;
|
|
assert(
|
|
inner.x >= outer.x - tolerance &&
|
|
inner.y >= outer.y - tolerance &&
|
|
inner.x + inner.width <= outer.x + outer.width + tolerance &&
|
|
inner.y + inner.height <= outer.y + outer.height + tolerance,
|
|
`${label} escaped its container: ${JSON.stringify({ inner, outer })}`
|
|
);
|
|
}
|
|
|
|
function boundsOverlap(left, right) {
|
|
const tolerance = 1;
|
|
return (
|
|
left.x < right.x + right.width - tolerance &&
|
|
left.x + left.width > right.x + tolerance &&
|
|
left.y < right.y + right.height - tolerance &&
|
|
left.y + left.height > right.y + tolerance
|
|
);
|
|
}
|
|
|
|
async function ensureLocalServer(url) {
|
|
if (await canReach(url)) {
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = new URL(url);
|
|
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) {
|
|
throw new Error(`No server responded at ${url}`);
|
|
}
|
|
|
|
const stderr = [];
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
'node_modules/vite/bin/vite.js',
|
|
'--host',
|
|
'127.0.0.1',
|
|
'--port',
|
|
parsed.port || '41811',
|
|
'--strictPort'
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true
|
|
}
|
|
);
|
|
child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
|
|
child.stdout.on('data', () => {});
|
|
|
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
if (await canReach(url)) {
|
|
return child;
|
|
}
|
|
await delay(250);
|
|
}
|
|
|
|
await stopServerProcess(child);
|
|
throw new Error(
|
|
`Vite server did not start at ${url}\n${stderr.join('')}`
|
|
);
|
|
}
|
|
|
|
async function canReach(url) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
signal: AbortSignal.timeout(1000)
|
|
});
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function stopServerProcess(child) {
|
|
if (!child || child.exitCode !== null) {
|
|
return;
|
|
}
|
|
const exited = new Promise((resolve) => child.once('exit', resolve));
|
|
child.kill();
|
|
await Promise.race([exited, delay(5000)]);
|
|
if (child.exitCode === null) {
|
|
child.kill('SIGKILL');
|
|
await Promise.race([exited, delay(2000)]);
|
|
}
|
|
}
|