feat: surface turn-end risk forecasts
This commit is contained in:
855
scripts/verify-turn-end-risk-browser.mjs
Normal file
855
scripts/verify-turn-end-risk-browser.mjs
Normal file
@@ -0,0 +1,855 @@
|
||||
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)]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user