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)]); } }