import assert from 'node:assert/strict'; import { createServer } from 'vite'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; const server = await createServer({ logLevel: 'error', server: { host: '127.0.0.1', port: 0, hmr: false }, appType: 'spa' }); let browser; try { await server.listen(); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const { enemyIntentOpeningGuide, isEnemyIntentCounterplayAvailable, isEnemyIntentForecastAvailable } = await server.ssrLoadModule('/src/game/data/enemyIntent.ts'); const scenarios = Object.values(battleScenarios); assert.equal(scenarios.length, 66, 'Enemy-intent rollout must cover the complete 66-battle campaign.'); const playablePhases = ['idle', 'moving', 'command', 'targeting', 'animating']; scenarios.forEach((scenario) => { assert.ok(scenario.sortie, `${scenario.id}: sortie roles are required for counterplay contribution reporting.`); assert.ok(scenario.units.some((unit) => unit.faction === 'ally'), `${scenario.id}: missing allied intent target.`); assert.ok(scenario.units.some((unit) => unit.faction === 'enemy'), `${scenario.id}: missing enemy intent source.`); playablePhases.forEach((phase) => { assert.equal( isEnemyIntentForecastAvailable({ activeFaction: 'ally', phase, battleResolved: false }), true, `${scenario.id}: intent forecast unexpectedly disabled during ${phase}.` ); assert.equal( isEnemyIntentCounterplayAvailable({ phase, battleResolved: false }), true, `${scenario.id}: counterplay unexpectedly disabled during ${phase}.` ); }); }); assert.equal( isEnemyIntentForecastAvailable({ activeFaction: 'ally', phase: 'deployment', battleResolved: false }), false, 'Deployment must not show forecasts before positions are confirmed.' ); assert.equal( isEnemyIntentForecastAvailable({ activeFaction: 'enemy', phase: 'animating', battleResolved: false }), false, 'Enemy execution must hide stale forecast visuals.' ); assert.equal( isEnemyIntentForecastAvailable({ activeFaction: 'ally', phase: 'resolved', battleResolved: true }), false, 'Settled battles must not show intent forecasts.' ); assert.equal( isEnemyIntentCounterplayAvailable({ phase: 'animating', battleResolved: false }), true, 'The final allied action must remain eligible for a defeat counterplay before settlement.' ); assert.equal( isEnemyIntentCounterplayAvailable({ phase: 'resolved', battleResolved: true }), false, 'Resolved battles must not record late counterplay events.' ); const commonGuide = enemyIntentOpeningGuide(); const initiativeGuide = enemyIntentOpeningGuide(3); assert.match(commonGuide, /이동·선제·전열 방호로 파훼/, 'Common intent guide must explain universal counterplay.'); assert.doesNotMatch(commonGuide, /전술 명령/, 'Campaign-wide guide must not promise the second-battle-only command.'); assert.match(initiativeGuide, /파훼 3회 → 전술 명령/, 'The legacy pursuit battle must retain its initiative instruction.'); const address = server.httpServer?.address(); assert(address && typeof address !== 'string', 'Expected the enemy-intent verification server to listen on a local port.'); const targetUrl = `http://127.0.0.1:${address.port}/`; browser = await chromium.launch({ headless: true }); const firstScenario = battleScenarios['first-battle-zhuo-commandery']; const genericScenario = scenarios.find((scenario, index) => index >= 32 && scenario.id !== firstScenario.id); assert(firstScenario && genericScenario, 'Expected first and mid-campaign runtime fixtures.'); const runtimeScope = process.env.HEROS_ENEMY_INTENT_SCOPE; if (runtimeScope !== 'generic') { await verifyRuntimeIntentState(browser, targetUrl, firstScenario, { expectTutorial: true }); } if (runtimeScope !== 'first') { await verifyRuntimeIntentState(browser, targetUrl, genericScenario, { verifySaveResume: true }); } console.log( `Verified campaign-wide enemy intent for ${scenarios.length} battles plus ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} deployment, first tutorial, save-resume, final-defeat, settlement, and generalized-guide checks.` ); } finally { await browser?.close(); await server.close(); } async function verifyRuntimeIntentState(browserInstance, targetUrl, scenario, options = {}) { const url = new URL(targetUrl); url.searchParams.set('debug', '1'); url.searchParams.set('renderer', 'canvas'); url.searchParams.set('debugBattle', scenario.id); const page = await browserInstance.newPage(desktopBrowserContextOptions); const pageErrors = []; const consoleErrors = []; page.on('pageerror', (error) => pageErrors.push(error.message)); page.on('console', (message) => { if (message.type() === 'error') { consoleErrors.push(message.text()); } }); try { await page.addInitScript(() => window.localStorage.clear()); await page.goto(url.toString(), { waitUntil: 'domcontentloaded' }); await waitForBattleDebugReady(page, scenario.id); const initialProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); return { state: window.__HEROS_DEBUG__?.battle(), viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio }, scene: scene ? { width: scene.scale.width, height: scene.scale.height } : null }; }); const initial = initialProbe.state; assert.deepEqual( initialProbe.viewport, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height, dpr: 1 }, `${scenario.id}: browser did not use the required FHD DPR1 viewport.` ); assert.deepEqual( initialProbe.scene, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height }, `${scenario.id}: battle scene did not use FHD dimensions.` ); if (initial?.phase === 'deployment') { assert.equal(initial.counterplayFeedback?.active, false, `${scenario.id}: counterplay active during deployment.`); assert.equal(initial.counterplayFeedback?.forecastVisible, false, `${scenario.id}: forecast visible during deployment.`); assert.equal(initial.enemyIntentPreviews?.length, 0, `${scenario.id}: deployment produced stale intent plans.`); assert.equal(initial.enemyIntentVisualCount, 0, `${scenario.id}: deployment produced intent visuals.`); await page.evaluate(() => window.__HEROS_GAME__?.scene.getScene('BattleScene')?.confirmPreBattleDeployment?.()); } await page.waitForFunction((battleId) => { try { const state = window.__HEROS_DEBUG__?.battle(); const assetsReady = state?.combatAssets?.status === 'ready' || state?.combatAssets?.status === 'degraded'; return state?.battleId === battleId && state.phase === 'idle' && assetsReady; } catch { return false; } }, scenario.id, { timeout: 90000 }); if (options.expectTutorial) { await page.waitForFunction( () => { try { return window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial?.active === true; } catch { return false; } }, undefined, { timeout: 30000 } ); } await page.waitForFunction( () => window.__HEROS_DEBUG__?.battle()?.triggeredBattleEvents?.includes('opening') === true, undefined, { timeout: 15000 } ); const live = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const aliveEnemyCount = live?.units?.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length ?? 0; const deployedAllyCount = live?.units?.filter((unit) => unit.faction === 'ally').length ?? 0; const liveTileCount = new Set( live?.units?.filter((unit) => unit.hp > 0).map((unit) => `${unit.x}:${unit.y}`) ?? [] ).size; const liveUnitCount = live?.units?.filter((unit) => unit.hp > 0).length ?? 0; assert.ok(deployedAllyCount <= scenario.sortie.sortieLimit, `${scenario.id}: fallback sortie exceeded its limit.`); assert.equal(liveTileCount, liveUnitCount, `${scenario.id}: fallback sortie created overlapping live units.`); assert.equal(live?.counterplayFeedback?.active, true, `${scenario.id}: campaign counterplay inactive.`); assert.equal(live?.counterplayFeedback?.forecastVisible, true, `${scenario.id}: allied forecast inactive.`); assert.equal(live?.counterplayFeedback?.scope, 'campaign', `${scenario.id}: intent scope is not campaign-wide.`); assert.equal(live?.enemyIntentPreviews?.length, aliveEnemyCount, `${scenario.id}: not every enemy received a plan.`); assert.equal(live?.counterplayFeedback?.byUnit?.length, deployedAllyCount, `${scenario.id}: contribution roles omit allies.`); assert.ok(live?.enemyIntentVisualCount > 0, `${scenario.id}: no visible intent marker was created.`); assert.ok(live?.triggeredBattleEvents?.includes('opening'), `${scenario.id}: opening guide event did not run.`); if (options.expectTutorial) { assert.equal(live?.firstBattleTutorial?.active, true, 'First-battle tutorial was displaced by intent rendering.'); assert.ok(live?.firstBattleTutorial?.step, 'First-battle tutorial lost its active step.'); } if (options.verifySaveResume) { const beforeSave = normalizedIntentPlans(live.enemyIntentPreviews); const saved = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene?.saveBattleState?.(1); return Object.keys(window.localStorage).some((key) => key.includes(`battle:${window.__HEROS_DEBUG__?.battle()?.battleId}:slot-1`)); }); assert.equal(saved, true, `${scenario.id}: battle slot was not persisted.`); await page.waitForFunction(() => { try { const transition = window.__HEROS_DEBUG__?.battle()?.battleHud?.panelTransition; return !transition || (!transition.active && transition.completedRevision === transition.revision); } catch { return false; } }, undefined, { timeout: 30000 }); await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); const restoreProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene?.clearEnemyIntentForecast?.(); const afterClear = scene?.enemyIntentForecasts?.size ?? -1; const readable = Boolean(scene?.readBattleSaveState?.(1)); scene?.loadBattleState?.(1); return { afterClear, readable, afterLoad: scene?.enemyIntentForecasts?.size ?? -1, phase: scene?.phase, activeFaction: scene?.activeFaction, currentPlanCount: scene?.currentEnemyActionPlans?.().length ?? -1 }; }); assert.equal(restoreProbe.readable, true, `${scenario.id}: newly written battle save failed validation.`); try { await page.waitForFunction((expectedCount) => { try { const state = window.__HEROS_DEBUG__?.battle(); return ( state?.phase === 'idle' && state.counterplayFeedback?.forecastVisible === true && state.enemyIntentPreviews?.length === expectedCount ); } catch { return false; } }, aliveEnemyCount, { timeout: 30000 }); } catch (error) { const diagnostic = await page.evaluate(() => { let state; let debugError; try { state = window.__HEROS_DEBUG__?.battle(); } catch (caught) { debugError = String(caught); } return { state: state ? { battleId: state.battleId, phase: state.phase, activeFaction: state.activeFaction, previewCount: state.enemyIntentPreviews?.length ?? -1, aliveEnemyCount: state.units?.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length ?? -1, forecastVisible: state.counterplayFeedback?.forecastVisible ?? false, enemyIntentVisualCount: state.enemyIntentVisualCount ?? -1 } : undefined, debugError, storage: Object.keys(window.localStorage) .filter((key) => key.includes('battle') || key.includes('campaign')) .map((key) => ({ key, size: window.localStorage.getItem(key)?.length ?? 0 })) }; }); throw new Error( `${scenario.id}: save-resume intent wait failed: ${JSON.stringify({ restoreProbe, ...diagnostic, consoleErrors })}`, { cause: error } ); } const resumed = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); assert.deepEqual( normalizedIntentPlans(resumed.enemyIntentPreviews), beforeSave, `${scenario.id}: save-resume changed deterministic intent targets or damage.` ); assert.equal(resumed.counterplayFeedback?.recentEvents?.length, 0, `${scenario.id}: resume created phantom counterplay.`); } assert.deepEqual(pageErrors, [], `${scenario.id}: browser errors: ${pageErrors.join(' | ')}`); assert.deepEqual(consoleErrors, [], `${scenario.id}: console errors: ${consoleErrors.join(' | ')}`); } finally { await page.close(); } } async function waitForBattleDebugReady(page, battleId) { await page.waitForFunction(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if (!scene?.scene?.isActive?.() || !scene.layout || !scene.mapBackground || scene.unitViews?.size === 0) { return false; } if (scene.phase === 'deployment') { return Boolean(scene.deploymentPanelLayout && scene.sidePanelObjects?.length > 0); } return Boolean(scene.currentSidePanelView && scene.sideQuickTabLayout && scene.sidePanelObjects?.length > 0); }, undefined, { timeout: 90000 }); await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await page.waitForFunction((expectedBattleId) => { try { const state = window.__HEROS_DEBUG__?.battle(); return ( state?.battleId === expectedBattleId && state.mapBackgroundReady === true && (state.phase === 'deployment' || state.phase === 'idle') ); } catch { return false; } }, battleId, { timeout: 30000 }); } function normalizedIntentPlans(plans = []) { return plans .map((plan) => ({ enemyId: plan.enemyId, kind: plan.kind, destination: plan.destination, targetId: plan.targetId, usableId: plan.usableId, damage: plan.damage, hitRate: plan.hitRate })) .sort((left, right) => left.enemyId.localeCompare(right.enemyId)); }