import assert from 'node:assert/strict'; import { mkdirSync } from 'node:fs'; import { chromium } from 'playwright'; import { createServer } from 'vite'; import { desktopBrowserContextOptions, desktopBrowserDeviceScaleFactor, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; const sourceBattleId = 'seventh-battle-xuzhou-rescue'; const targetBattleId = 'eighth-battle-xiaopei-supply-road'; const informationVisitId = 'city-xuzhou-xiaopei-ledger'; const dialogueId = 'city-xuzhou-liu-mi-supply-trust'; const aidChoiceId = 'city-xuzhou-share-storehouse-duty'; const encourageChoiceId = 'city-xuzhou-trust-mi-zhu-ledger'; const miZhuUnitId = 'mi-zhu'; const trackedEnemyUnitIds = [ 'xiaopei-guard-a', 'xiaopei-guard-b' ]; const equipmentOfferId = 'city-xuzhou-iron-spear'; const rendererFixtures = { canvas: { renderer: 'canvas', expectedRendererType: 1 }, webgl: { renderer: 'webgl', expectedRendererType: 2 } }; const requestedRenderer = cliOption('renderer') ?? process.env.VERIFY_XUZHOU_STAY_RENDERER ?? 'both'; assert( ['canvas', 'webgl', 'both'].includes(requestedRenderer), `Unknown renderer "${requestedRenderer}". Use canvas, webgl, or both.` ); const renderers = requestedRenderer === 'both' ? ['canvas', 'webgl'] : [requestedRenderer]; const server = await createServer({ logLevel: 'error', server: { host: '127.0.0.1', port: 0, hmr: false }, appType: 'spa' }); let browser; try { mkdirSync('dist', { recursive: true }); await server.listen(); const address = server.httpServer?.address(); assert( address && typeof address !== 'string', 'Expected a local Vite verification server.' ); const baseUrl = `http://127.0.0.1:${address.port}/heros_web/`; browser = await chromium.launch({ headless: process.env.VERIFY_XUZHOU_STAY_HEADLESS !== '0' }); for (const renderer of renderers) { await verifyRenderer(browser, baseUrl, rendererFixtures[renderer]); } console.log( `Xuzhou stay battle payoff browser verification passed for ${renderers.join( ' + ' )} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, ` + `100% zoom, DPR ${desktopBrowserDeviceScaleFactor}: two exact deployment intent previews, ` + 'aid +4 and encourage +1-turn single-use branches, Mi Zhu absence, no-information baseline, ' + 'Mi Zhu defeat expiry, guard counterplay credit, equipment-safe header/chip layout, ' + 'battle-save restoration, campaign result/history persistence, and choice-aware Mi Zhu story/aftermath pages.' ); } finally { await browser?.close(); await server.close(); } async function verifyRenderer( browserInstance, baseUrl, rendererFixture ) { const context = await browserInstance.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()); } }); try { const url = new URL(baseUrl); url.searchParams.set('debug', '1'); url.searchParams.set('renderer', rendererFixture.renderer); await page.goto(url.toString(), { waitUntil: 'domcontentloaded', timeout: 90000 }); await page.evaluate(() => window.localStorage.clear()); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await assertFhdViewport(page, rendererFixture); await verifyAidBranch(page, rendererFixture.renderer); await verifyEncourageBranch(page, rendererFixture.renderer); await verifyMiZhuDefeatedBranch( page, rendererFixture.renderer ); await verifyMiZhuMissingBranch(page, rendererFixture.renderer); await verifyNoInformationBaseline(page, rendererFixture.renderer); await assertFhdViewport(page, rendererFixture); assert.deepEqual( pageErrors, [], `${rendererFixture.renderer}: expected no browser page errors: ${JSON.stringify( pageErrors.slice(-8) )}` ); assert.deepEqual( consoleErrors.filter( (message) => !message.includes( 'The AudioContext was not allowed to start' ) ), [], `${rendererFixture.renderer}: expected no browser console errors: ${JSON.stringify( consoleErrors.slice(-8) )}` ); } finally { await context.close(); } } async function verifyAidBranch(page, renderer) { const seed = await seedCampaign(page, { informationCompleted: true, choiceId: aidChoiceId, includeMiZhu: true, includeEquipment: true }); assert.equal(seed.saved, true, seed.reason); assert.equal(seed.choiceId, aidChoiceId); assert.equal(seed.equipmentOfferId, equipmentOfferId); let battle = await openBattle(page, { active: true, choiceId: aidChoiceId, miZhuDeployed: true }); assertDeploymentInformation(battle, `${renderer} aid`); assert.equal( battle.xuzhouStayBattlePayoff.effect?.bonusHealing, 4 ); assert( battle.battleHud.deploymentPanel.subtitleText.includes( '적 의도 2대' ), `${renderer}: aid deployment subtitle must retain the two revealed enemy intents.` ); assert( battle.battleHud.deploymentPanel.subtitleText.includes( '응급 +4' ), `${renderer}: aid deployment subtitle must state the +4 payoff.` ); assert( battle.battleHud.deploymentPanel.subtitleText.includes( '철창' ), `${renderer}: the existing Xuzhou equipment must remain visible beside the stay payoff.` ); assert( battle.battleHud.deploymentPanel.subtitleText .split('\n') .some((line) => line.startsWith('선택 ')), `${renderer}: the deployment header must preserve the selected strategy/order line.` ); assertDeploymentHeaderLayout( battle.battleHud.deploymentPanel, `${renderer} aid + equipment` ); await capture( page, screenshotPath(renderer, 'aid-deployment-equipment') ); battle = await startBattleWithRealClick(page); assert.equal( battle.xuzhouStayBattlePayoff.chip.visible, true ); assert.equal( battle.cityEquipmentPreparation.chip.visible, true ); const activeChips = [ { id: 'stay-payoff', bounds: battle.xuzhouStayBattlePayoff.chip.bounds }, { id: 'city-equipment', bounds: battle.cityEquipmentPreparation.chip.bounds } ]; assertNoOverlappingBounds( activeChips, `${renderer}: active Xuzhou HUD chips` ); const headerRegions = Object.entries({ title: battle.battleHud.header.titleBounds, turn: battle.battleHud.header.turnBounds, speed: battle.battleHud.header.speedBounds, objective: battle.battleHud.header.objectiveBounds, defeat: battle.battleHud.header.defeatBounds, 'sortie-order': battle.battleHud.header.sortieOrderBounds }).filter(([, bounds]) => bounds); activeChips.forEach((chip) => { headerRegions.forEach(([id, bounds]) => assertNoOverlappingBounds( [chip, { id, bounds }], `${renderer}: ${chip.id} must stay below the battle header` ) ); if (battle.battleHud.quickTabs.visible) { assertNoOverlappingBounds( [ chip, { id: 'quick-tabs', bounds: battle.battleHud.quickTabs.bounds } ], `${renderer}: ${chip.id} must reserve space above quick tabs` ); } }); const roundTrip = await page.evaluate( ({ trackedId, untrackedId, expectedBattleId }) => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); if ( !scene || typeof scene.debugResolveXuzhouStaySupport !== 'function' || typeof scene.debugRecordXuzhouStayCounterplay !== 'function' || typeof scene.debugRecordXuzhouStayGuardCounterplay !== 'function' || typeof scene.createBattleSaveState !== 'function' || typeof scene.applyBattleSaveState !== 'function' ) { throw new Error( 'BattleScene Xuzhou support/counterplay/save hooks are unavailable.' ); } const before = structuredClone( window.__HEROS_DEBUG__.battle() .xuzhouStayBattlePayoff ); const first = scene.debugResolveXuzhouStaySupport('aid'); const beforeTracked = first.runtime.informationCounterplays; const guard = scene.debugRecordXuzhouStayGuardCounterplay( trackedId ); const tracked = guard.runtime; const beforeUntracked = tracked.informationCounterplays; const untracked = scene.debugRecordXuzhouStayCounterplay(untrackedId); const fullSave = scene.createBattleSaveState(); if ( fullSave.battleId !== expectedBattleId || !fullSave.xuzhouStayBattlePayoff ) { throw new Error( 'Battle save did not include the Xuzhou stay payoff.' ); } const saveSnapshot = structuredClone( fullSave.xuzhouStayBattlePayoff ); const detached = fullSave.xuzhouStayBattlePayoff !== tracked; scene.xuzhouStayBattlePayoffRuntime.supportAttempts = 0; scene.xuzhouStayBattlePayoffRuntime.supportUses = 0; scene.xuzhouStayBattlePayoffRuntime.bonusHealing = 0; scene.xuzhouStayBattlePayoffRuntime.informationCounterplays = 0; scene.renderTacticalInitiativeChip(); const reset = structuredClone( window.__HEROS_DEBUG__.battle() .xuzhouStayBattlePayoff ); scene.applyBattleSaveState(structuredClone(fullSave)); scene.renderTacticalInitiativeChip(); const restored = structuredClone( window.__HEROS_DEBUG__.battle() .xuzhouStayBattlePayoff ); const second = scene.debugResolveXuzhouStaySupport('aid'); scene.renderTacticalInitiativeChip(); const finalState = structuredClone( window.__HEROS_DEBUG__.battle() .xuzhouStayBattlePayoff ); return { before, first, trackedDelta: tracked.informationCounterplays - beforeTracked, untrackedDelta: untracked.informationCounterplays - beforeUntracked, saveSnapshot, detached, reset, restored, second, finalState }; }, { trackedId: trackedEnemyUnitIds[0], untrackedId: 'xiaopei-raider-a', expectedBattleId: targetBattleId } ); assert.equal( roundTrip.first.cityStayBonusHealing, 4, `${renderer}: the first aid must gain exactly +4 healing.` ); assert.equal(roundTrip.first.cityStayBonusBuffTurns, 0); assert.equal(roundTrip.trackedDelta, 1); assert.equal(roundTrip.untrackedDelta, 0); assert.equal(roundTrip.saveSnapshot.supportUses, 1); assert.equal(roundTrip.saveSnapshot.bonusHealing, 4); assert.equal( roundTrip.saveSnapshot.informationCounterplays, 1 ); assert.equal(roundTrip.detached, true); assert.equal(roundTrip.reset.runtime.supportUses, 0); assert.equal(roundTrip.reset.runtime.bonusHealing, 0); assert.equal( roundTrip.reset.runtime.informationCounterplays, 0 ); assert.deepEqual( roundTrip.restored.runtime, roundTrip.saveSnapshot, `${renderer}: create/apply must restore the exact support-used state.` ); assert.equal( roundTrip.second.cityStayBonusHealing, 0, `${renderer}: the second aid must not receive the one-use bonus.` ); assert.equal(roundTrip.finalState.runtime.supportUses, 1); assert.equal(roundTrip.finalState.chip.visible, true); assertBoundsInsideViewport( roundTrip.finalState.chip.bounds, `${renderer}: restored aid payoff chip` ); await capture(page, screenshotPath(renderer, 'aid-used-restored')); const result = await forceVictoryAndReadPayoff(page); assert.equal(result.battle.battleOutcome, 'victory'); assert.equal(result.battle.resultVisible, true); assert.equal( result.battle.xuzhouStayBattlePayoff.result.supportUses, 1 ); assert.equal( result.battle.xuzhouStayBattlePayoff.result.bonusHealing, 4 ); assert.equal( result.battle.xuzhouStayBattlePayoff.result .informationCounterplays, 1 ); assert( result.battle.xuzhouStayBattlePayoff.compactResultText.includes( '장부 파훼 1' ) ); assert( result.battle.xuzhouStayBattlePayoff.compactResultText.includes( '응급 +4' ) ); assert.deepEqual( result.campaign.reportPayoff, result.battle.xuzhouStayBattlePayoff.result ); assert.deepEqual( result.campaign.historyPayoff, result.battle.xuzhouStayBattlePayoff.result ); assert( result.visibleResultTexts.some( (text) => text.includes('장부 파훼 1') && text.includes('응급 +4') && text.includes('철창') ), `${renderer}: result subtitle must compactly retain both stay and equipment payoffs: ${JSON.stringify( result.visibleResultTexts )}` ); await capture(page, screenshotPath(renderer, 'aid-result')); await verifyNarrativeMemoryPages(page, renderer); } async function verifyNarrativeMemoryPages(page, renderer) { const intro = await openXuzhouNarrativePage(page, 'story'); assert.equal(intro.currentPageId, 'eighth-xuzhou-stay-memory-intro'); assert.equal(intro.currentSpeaker, '미축'); assert.equal(intro.portraitKey, 'mi-zhu'); assert( intro.portraitTextureKey?.startsWith('portrait-mi-zhu'), `${renderer}: the Xuzhou memory page must render Mi Zhu's face portrait: ${JSON.stringify(intro)}` ); assert( intro.currentText.includes('서주에서 함께 살핀 장부') && intro.currentText.includes('창고 당번을 함께 나누기로 한 뜻'), `${renderer}: the pre-battle story must react to the exact shared-storehouse choice: ${JSON.stringify(intro)}` ); assert.deepEqual( intro.pageIds.slice(2, 5), [ 'eighth-xiaopei-road', 'eighth-xuzhou-stay-memory-intro', 'eighth-sortie-choice' ], `${renderer}: the memory page must sit immediately after the Xiaopei road briefing.` ); assert.equal(intro.xuzhouStayNarrativeMemory?.stage, 'story'); assert.equal( intro.xuzhouStayNarrativeMemory?.choiceId, aidChoiceId ); assert.equal( intro.xuzhouStayNarrativeMemory?.currentPageApplied, true ); assertBoundsInside( intro.bodyBounds, intro.dialogPanelBounds, `${renderer}: Xuzhou intro dialogue body` ); await capture( page, screenshotPath(renderer, 'narrative-intro-aid') ); const aftermath = await openXuzhouNarrativePage( page, 'aftermath' ); assert.equal( aftermath.currentPageId, 'eighth-xuzhou-stay-memory-aftermath' ); assert.equal(aftermath.currentSpeaker, '미축'); assert.equal(aftermath.portraitKey, 'mi-zhu'); assert( aftermath.portraitTextureKey?.startsWith( 'portrait-mi-zhu' ), `${renderer}: the Xuzhou aftermath must retain Mi Zhu's face portrait: ${JSON.stringify(aftermath)}` ); assert( aftermath.currentText.includes('움직임 1곳을 파훼') && aftermath.currentText.includes('응급 회복을 4만큼 더 보탰습니다'), `${renderer}: the aftermath must narrate the historical counterplay and consumed aid bonus: ${JSON.stringify(aftermath)}` ); assert.deepEqual( aftermath.pageIds.slice(0, 3), [ 'eighth-victory-supply-road', 'eighth-xuzhou-stay-memory-aftermath', 'eighth-lu-bu-arrival' ], `${renderer}: the historical result page must sit before Lu Bu's arrival.` ); assert.equal( aftermath.xuzhouStayNarrativeMemory?.stage, 'aftermath' ); assert.equal( aftermath.xuzhouStayNarrativeMemory ?.informationCounterplays, 1 ); assert.equal( aftermath.xuzhouStayNarrativeMemory?.supportOutcome, 'used' ); assert.equal( aftermath.xuzhouStayNarrativeMemory?.miZhuStatus, 'deployed' ); assert.equal( aftermath.xuzhouStayNarrativeMemory?.currentPageApplied, true ); assertBoundsInside( aftermath.bodyBounds, aftermath.dialogPanelBounds, `${renderer}: Xuzhou aftermath dialogue body` ); await capture( page, screenshotPath(renderer, 'narrative-aftermath-aid') ); await page.evaluate(() => { const game = window.__HEROS_GAME__; game?.scene.stop('StoryScene'); game?.scene.stop('BattleScene'); game?.scene.start('TitleScene'); }); await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes( 'TitleScene' ) ); } async function openXuzhouNarrativePage(page, stage) { await page.evaluate( async ({ requestedStage, battleId }) => { const [scenarioModule, lazySceneModule] = await Promise.all([ import('/heros_web/src/game/data/scenario.ts'), import('/heros_web/src/game/scenes/lazyScenes.ts') ]); const game = window.__HEROS_GAME__; if (!game) { throw new Error('Game instance is unavailable.'); } game.scene.stop('BattleScene'); game.scene.stop('StoryScene'); await lazySceneModule.startLazySceneFromGame( game, 'StoryScene', { pages: structuredClone( requestedStage === 'story' ? scenarioModule.eighthBattleIntroPages : scenarioModule.eighthBattleVictoryPages ), nextScene: 'TitleScene', presentationBattleId: battleId, presentationStage: requestedStage } ); }, { requestedStage: stage, battleId: targetBattleId } ); await page.waitForFunction( () => { const story = window.__HEROS_DEBUG__?.scene( 'StoryScene' )?.getDebugState?.(); return ( window.__HEROS_DEBUG__?.activeScenes?.().includes( 'StoryScene' ) && story?.ready === true ); }, undefined, { timeout: 90000 } ); for (let index = 0; index < 8; index += 1) { await page.waitForFunction( () => { const scene = window.__HEROS_DEBUG__?.scene('StoryScene'); return ( typeof scene?.advance === 'function' && scene.transitioning === false ); }, undefined, { timeout: 30000 } ); const current = await readStoryWithPageIds(page); if ( current.xuzhouStayNarrativeMemory ?.currentPageApplied === true ) { return current; } assert.equal( current.isLastPage, false, `The ${stage} story ended before its Xuzhou memory page: ${JSON.stringify(current)}` ); const previousPageIndex = current.pageIndex; await page.evaluate(() => window.__HEROS_DEBUG__?.scene( 'StoryScene' )?.advance?.() ); await page.waitForFunction( (before) => { const story = window.__HEROS_DEBUG__?.scene( 'StoryScene' )?.getDebugState?.(); return ( story?.ready === true && story.pageIndex > before ); }, previousPageIndex, { timeout: 90000 } ); } throw new Error( `Could not reach the ${stage} Xuzhou memory page.` ); } async function readStoryWithPageIds(page) { return page.evaluate(() => { const scene = window.__HEROS_DEBUG__?.scene('StoryScene'); return { ...scene?.getDebugState?.(), pageIds: (scene?.pages ?? []).map((page) => page.id) }; }); } async function verifyEncourageBranch(page, renderer) { const seed = await seedCampaign(page, { informationCompleted: true, choiceId: encourageChoiceId, includeMiZhu: true, includeEquipment: false }); assert.equal(seed.saved, true, seed.reason); let battle = await openBattle(page, { active: true, choiceId: encourageChoiceId, miZhuDeployed: true }); assertDeploymentInformation(battle, `${renderer} encourage`); assert( battle.battleHud.deploymentPanel.subtitleText.includes( '격려 +1턴' ) ); battle = await startBattleWithRealClick(page); const support = await page.evaluate(() => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); const first = scene.debugResolveXuzhouStaySupport('encourage'); const second = scene.debugResolveXuzhouStaySupport('encourage'); scene.renderTacticalInitiativeChip(); return { first, second, payoff: structuredClone( window.__HEROS_DEBUG__.battle() .xuzhouStayBattlePayoff ), chipTexts: (scene.tacticalInitiativeChipObjects ?? []) .filter( (object) => object?.type === 'Text' && object.active && object.visible ) .map((object) => object.text) }; }); assert.equal(support.first.cityStayBonusHealing, 0); assert.equal(support.first.cityStayBonusBuffTurns, 1); assert.equal( support.second.cityStayBonusBuffTurns, 0, `${renderer}: the second encourage must not receive the one-use duration bonus.` ); assert.equal(support.payoff.runtime.supportUses, 1); assert.equal(support.payoff.runtime.bonusBuffTurns, 1); assert( support.chipTexts.some((text) => text.includes('격려 +1턴') ) ); await capture(page, screenshotPath(renderer, 'encourage-used')); } async function verifyMiZhuDefeatedBranch(page, renderer) { const seed = await seedCampaign(page, { informationCompleted: true, choiceId: aidChoiceId, includeMiZhu: true, includeEquipment: false }); assert.equal(seed.saved, true, seed.reason); let battle = await openBattle(page, { active: true, choiceId: aidChoiceId, miZhuDeployed: true }); assertDeploymentInformation( battle, `${renderer} Mi Zhu defeated` ); battle = await startBattleWithRealClick(page); const defeated = await page.evaluate(() => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); if ( !scene || typeof scene.debugSetXuzhouStayMiZhuHp !== 'function' ) { throw new Error( 'BattleScene Mi Zhu defeat debug hook is unavailable.' ); } const payoff = scene.debugSetXuzhouStayMiZhuHp(0); return { payoff: structuredClone(payoff), support: scene.debugResolveXuzhouStaySupport('aid') ?? null, chipTexts: (scene.tacticalInitiativeChipObjects ?? []) .filter( (object) => object?.type === 'Text' && object.active && object.visible ) .map((object) => object.text) }; }); assert.equal(defeated.payoff.effectActive, false); assert.equal(defeated.payoff.runtime.supportUses, 0); assert.equal(defeated.support, null); assert( defeated.chipTexts.some((text) => text.includes('미축 퇴각') ), `${renderer}: an unused support payoff must visibly expire when Mi Zhu is defeated.` ); assert( defeated.payoff.resultText.includes('미축 퇴각'), `${renderer}: result feedback must preserve the defeat reason.` ); await capture( page, screenshotPath(renderer, 'mi-zhu-defeated-active') ); } async function verifyMiZhuMissingBranch(page, renderer) { const seed = await seedCampaign(page, { informationCompleted: true, choiceId: aidChoiceId, includeMiZhu: false, includeEquipment: false }); assert.equal(seed.saved, true, seed.reason); let battle = await openBattle(page, { active: true, choiceId: aidChoiceId, miZhuDeployed: false }); assertDeploymentInformation(battle, `${renderer} Mi Zhu missing`); assert.equal( battle.xuzhouStayBattlePayoff.effectActive, false ); assert.equal( battle.xuzhouStayBattlePayoff.mechanicsProbe.aid, null ); const deploymentTexts = await sceneTextValues( page, 'sidePanelObjects' ); assert( deploymentTexts.some((text) => text.includes('미축 미출전') ), `${renderer}: deployment notice must explicitly state that Mi Zhu is absent.` ); await capture( page, screenshotPath(renderer, 'mi-zhu-missing-deployment') ); battle = await startBattleWithRealClick(page); const missing = await page.evaluate(() => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); return { support: scene.debugResolveXuzhouStaySupport('aid') ?? null, payoff: structuredClone( window.__HEROS_DEBUG__.battle() .xuzhouStayBattlePayoff ), chipTexts: (scene.tacticalInitiativeChipObjects ?? []) .filter( (object) => object?.type === 'Text' && object.active && object.visible ) .map((object) => object.text) }; }); assert.equal(missing.support, null); assert.equal(missing.payoff.runtime.supportUses, 0); assert.equal(missing.payoff.runtime.bonusHealing, 0); assert.equal(missing.payoff.chip.visible, true); assert( missing.chipTexts.some((text) => text.includes('미축 미출전') ), `${renderer}: active payoff chip must preserve the no-sortie explanation.` ); await capture( page, screenshotPath(renderer, 'mi-zhu-missing-active') ); } async function verifyNoInformationBaseline(page, renderer) { const seed = await seedCampaign(page, { informationCompleted: false, choiceId: undefined, includeMiZhu: true, includeEquipment: false }); assert.equal(seed.saved, true, seed.reason); const battle = await openBattle(page, { active: false, choiceId: null, miZhuDeployed: false }); assert.equal(battle.phase, 'deployment'); assert.equal(battle.xuzhouStayBattlePayoff.active, false); assert.deepEqual( battle.enemyIntentPreviews, [], `${renderer}: the no-information deployment baseline must reveal no enemy intents.` ); assert.equal(battle.enemyIntentVisualCount, 0); assert( !battle.battleHud.deploymentPanel.subtitleText.includes( '관청 장부' ) ); assertDeploymentHeaderLayout( battle.battleHud.deploymentPanel, `${renderer} no-information baseline` ); await capture( page, screenshotPath(renderer, 'baseline-no-information') ); } async function seedCampaign(page, options) { return page.evaluate( async ({ sourceId, targetId, visitId, requestedDialogueId, selectedChoiceId, requestedMiZhuId, requestedOfferId, informationCompleted, includeMiZhu, includeEquipment }) => { const [campaignModule, scenarioModule] = await Promise.all([ import('/heros_web/src/game/state/campaignState.ts'), import('/heros_web/src/game/data/scenario.ts') ]); const current = campaignModule.getCampaignState(); const roster = scenarioModule.eighthBattleUnits .filter((unit) => unit.faction === 'ally') .filter( (unit) => includeMiZhu || unit.id !== requestedMiZhuId ) .map((unit) => structuredClone(unit)); const miZhu = roster.find( (unit) => unit.id === requestedMiZhuId ); if (includeMiZhu && !miZhu) { return { saved: false, reason: 'The eighth-battle Mi Zhu template is missing.' }; } if (includeEquipment && miZhu) { miZhu.equipment.weapon = { itemId: 'iron-spear', level: 1, exp: 6 }; } const now = new Date().toISOString(); const selectedSortieUnitIds = roster .map((unit) => unit.id) .filter((unitId) => [ 'liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', requestedMiZhuId ].includes(unitId) ); const next = { ...current, step: 'seventh-camp', latestBattleId: sourceId, roster, bonds: [], battleHistory: { [sourceId]: { battleId: sourceId, battleTitle: '서주 구원전', outcome: 'victory', turnNumber: 12, rewardGold: 0, itemRewards: [], objectives: [], units: structuredClone(roster), bonds: [], completedAt: now } }, completedCampVisits: informationCompleted ? [visitId] : [], completedCampDialogues: selectedChoiceId ? [requestedDialogueId] : [], campDialogueChoiceIds: selectedChoiceId ? { [requestedDialogueId]: selectedChoiceId } : {}, campVisitChoiceIds: {}, selectedSortieUnitIds, sortieFormationAssignments: { 'liu-bei': 'support', 'guan-yu': 'front', 'zhang-fei': 'flank', 'jian-yong': 'support', ...(includeMiZhu ? { [requestedMiZhuId]: 'reserve' } : {}) }, sortieItemAssignments: {}, sortieOrderSelection: { battleId: targetId, orderId: 'elite' }, inventory: {}, cityEquipmentPurchaseCounts: includeEquipment && includeMiZhu ? { [requestedOfferId]: 1 } : {} }; delete next.firstBattleReport; delete next.sortieResonanceSelection; delete next.sortieRecommendationSelection; delete next.pendingAftermathBattleId; delete next.cityEquipmentEquipIntent; if (includeEquipment && includeMiZhu) { next.cityEquipmentPreparation = { version: 1, selectionRecordId: 'city-equipment-preparation', cityStayId: 'xuzhou', sourceBattleId: sourceId, targetBattleId: targetId, offerId: requestedOfferId, itemId: 'iron-spear', slot: 'weapon', price: 620, purchaseNumber: 1, unitId: requestedMiZhuId, previousItemId: 'training-sword' }; } else { delete next.cityEquipmentPreparation; } const saved = campaignModule.setCampaignState(next); return { saved: true, choiceId: saved.campDialogueChoiceIds?.[ requestedDialogueId ] ?? null, informationCompleted: saved.completedCampVisits.includes(visitId), selectedSortieUnitIds: [ ...saved.selectedSortieUnitIds ], equipmentOfferId: saved.cityEquipmentPreparation?.offerId ?? null }; }, { sourceId: sourceBattleId, targetId: targetBattleId, visitId: informationVisitId, requestedDialogueId: dialogueId, selectedChoiceId: options.choiceId, requestedMiZhuId: miZhuUnitId, requestedOfferId: equipmentOfferId, informationCompleted: options.informationCompleted, includeMiZhu: options.includeMiZhu, includeEquipment: options.includeEquipment } ); } async function openBattle(page, expected) { await page.evaluate(async (battleId) => { await window.__HEROS_DEBUG__.goToBattle(battleId); }, targetBattleId); await page.waitForFunction( ({ battleId, expectedState }) => { const battle = window.__HEROS_DEBUG__?.battle?.(); const payoff = battle?.xuzhouStayBattlePayoff; return ( window.__HEROS_DEBUG__?.activeScenes?.().includes( 'BattleScene' ) && battle?.battleId === battleId && battle?.phase === 'deployment' && battle?.battleHud?.deploymentPanel?.startButtonBounds && payoff?.active === expectedState.active && payoff?.choiceId === expectedState.choiceId && (expectedState.active === false || payoff?.runtime?.miZhuDeployed === expectedState.miZhuDeployed) ); }, { battleId: targetBattleId, expectedState: expected }, { timeout: 90000 } ); return readBattle(page); } function assertDeploymentInformation(battle, label) { assert.equal(battle.battleId, targetBattleId); assert.equal(battle.phase, 'deployment'); assert.equal( battle.xuzhouStayBattlePayoff.active, true, `${label}: payoff memory must be active.` ); assert.equal( battle.xuzhouStayBattlePayoff.informationCompleted, true ); assert.deepEqual( [...battle.xuzhouStayBattlePayoff.trackedEnemyUnitIds].sort(), [...trackedEnemyUnitIds].sort() ); const previewIds = battle.enemyIntentPreviews .map((preview) => preview.enemyId) .sort(); assert.deepEqual( previewIds, [...trackedEnemyUnitIds].sort(), `${label}: deployment must reveal exactly the two warehouse-raider intents.` ); assert.equal(battle.enemyIntentPreviews.length, 2); } async function startBattleWithRealClick(page) { await page.waitForFunction( () => { const battle = window.__HEROS_DEBUG__?.battle?.(); return ( battle?.phase === 'deployment' && ['ready', 'degraded'].includes( battle?.combatAssets?.status ) && battle?.battleHud?.deploymentPanel ?.combatAssetsReady === true ); }, undefined, { timeout: 90000 } ); const battle = await readBattle(page); await clickSceneBounds( page, 'BattleScene', battle.battleHud.deploymentPanel.startButtonBounds ); await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.battle?.(); return ( state?.phase === 'idle' && state?.battleHud?.deploymentPanel === null && state?.xuzhouStayBattlePayoff?.chip?.visible === true ); }, undefined, { timeout: 90000 } ); return readBattle(page); } async function forceVictoryAndReadPayoff(page) { await page.evaluate(() => { window.__HEROS_DEBUG__.forceBattleOutcome('victory'); }); await page.waitForFunction( (battleId) => { const battle = window.__HEROS_DEBUG__?.battle?.(); return ( battle?.battleId === battleId && battle?.battleOutcome === 'victory' && battle?.resultVisible === true && battle?.resultSettlement?.status === 'complete' && battle?.xuzhouStayBattlePayoff?.result ?.supportUses === 1 ); }, targetBattleId, { timeout: 90000 } ); return page.evaluate(async (battleId) => { const battle = window.__HEROS_DEBUG__.battle(); const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); const campaignModule = await import( '/heros_web/src/game/state/campaignState.ts' ); const campaign = campaignModule.loadCampaignState(); const visibleResultTexts = (scene?.resultObjects ?? []) .filter( (object) => object?.type === 'Text' && object.active && object.visible && typeof object.text === 'string' ) .map((object) => object.text); return { battle, visibleResultTexts, campaign: { reportBattleId: campaign.firstBattleReport?.battleId ?? null, reportPayoff: campaign.firstBattleReport ?.xuzhouStayBattlePayoff ? structuredClone( campaign.firstBattleReport .xuzhouStayBattlePayoff ) : null, historyBattleId: campaign.battleHistory?.[battleId]?.battleId ?? null, historyPayoff: campaign.battleHistory?.[battleId] ?.xuzhouStayBattlePayoff ? structuredClone( campaign.battleHistory[battleId] .xuzhouStayBattlePayoff ) : null } }; }, targetBattleId); } function assertDeploymentHeaderLayout(panel, label) { assert(panel, `${label}: deployment panel is required.`); assertBoundsInsideViewport( panel.headerBounds, `${label}: header` ); assertBoundsInside( panel.statusBounds, panel.headerBounds, `${label}: status` ); if (panel.preparationSummaryBounds) { assertBoundsInside( panel.preparationSummaryBounds, panel.headerBounds, `${label}: preparation summary` ); assertNoOverlappingBounds( [ { id: 'status', bounds: panel.statusBounds }, { id: 'preparation-summary', bounds: panel.preparationSummaryBounds } ], `${label}: header text regions` ); } assertBoundsInsideViewport( panel.noticeBounds, `${label}: notice` ); assertBoundsInside( panel.noticeTextBounds, panel.noticeBounds, `${label}: notice text` ); assertNoOverlappingBounds( [ { id: 'header', bounds: panel.headerBounds }, { id: 'notice', bounds: panel.noticeBounds }, ...panel.roleBounds.map((bounds, index) => ({ id: `role-${index + 1}`, bounds })), { id: 'restore', bounds: panel.restoreButtonBounds }, { id: 'start', bounds: panel.startButtonBounds } ], `${label}: primary deployment regions` ); } async function sceneTextValues(page, collectionName) { return page.evaluate((requestedCollectionName) => { const scene = window.__HEROS_DEBUG__?.scene('BattleScene'); return (scene?.[requestedCollectionName] ?? []) .filter( (object) => object?.type === 'Text' && object.active && object.visible && typeof object.text === 'string' ) .map((object) => object.text); }, collectionName); } async function readBattle(page) { return page.evaluate( () => window.__HEROS_DEBUG__?.battle?.() ); } async function waitForDebugApi(page) { await page.waitForFunction( () => document.querySelector('canvas') !== null && window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 } ); } async function assertFhdViewport(page, rendererFixture) { const viewport = await page.evaluate(() => { const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); return { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio, visualScale: window.visualViewport?.scale ?? 1, rendererType: window.__HEROS_GAME__?.renderer?.type, canvas: canvas ? { width: canvas.width, height: canvas.height, clientWidth: canvas.clientWidth, clientHeight: canvas.clientHeight, bounds: bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null } : null }; }); assert.equal(viewport.width, desktopBrowserViewport.width); assert.equal(viewport.height, desktopBrowserViewport.height); assert.equal(viewport.dpr, desktopBrowserDeviceScaleFactor); assert.equal(viewport.visualScale, 1); assert.equal( viewport.rendererType, rendererFixture.expectedRendererType ); assert.equal(viewport.canvas?.width, desktopBrowserViewport.width); assert.equal(viewport.canvas?.height, desktopBrowserViewport.height); assert.equal( viewport.canvas?.clientWidth, desktopBrowserViewport.width ); assert.equal( viewport.canvas?.clientHeight, desktopBrowserViewport.height ); assert.deepEqual(viewport.canvas?.bounds, { x: 0, y: 0, width: desktopBrowserViewport.width, height: desktopBrowserViewport.height }); } async function clickSceneBounds(page, sceneKey, bounds) { assertFiniteBounds(bounds, `${sceneKey} click bounds`); const point = await page.evaluate( ({ requestedSceneKey, requestedBounds }) => { const scene = window.__HEROS_DEBUG__?.scene(requestedSceneKey); const canvas = document.querySelector('canvas'); const canvasBounds = canvas?.getBoundingClientRect(); if (!scene || !canvasBounds) { return null; } return { x: canvasBounds.left + (requestedBounds.x + requestedBounds.width / 2) * canvasBounds.width / scene.scale.width, y: canvasBounds.top + (requestedBounds.y + requestedBounds.height / 2) * canvasBounds.height / scene.scale.height }; }, { requestedSceneKey: sceneKey, requestedBounds: bounds } ); assert( point && Number.isFinite(point.x) && Number.isFinite(point.y) ); await page.mouse.click(point.x, point.y); } function assertFiniteBounds(bounds, label) { assert(bounds, `${label}: bounds are required.`); for (const key of ['x', 'y', 'width', 'height']) { assert( Number.isFinite(bounds[key]), `${label}: ${key} must be finite.` ); } assert(bounds.width > 0, `${label}: width must be positive.`); assert(bounds.height > 0, `${label}: height must be positive.`); } function assertBoundsInsideViewport(bounds, label) { assertBoundsInside( bounds, { x: 0, y: 0, width: desktopBrowserViewport.width, height: desktopBrowserViewport.height }, label ); } function assertBoundsInside(bounds, container, label) { assertFiniteBounds(bounds, label); assertFiniteBounds(container, `${label} container`); const epsilon = 0.01; assert( bounds.x >= container.x - epsilon && bounds.y >= container.y - epsilon && bounds.x + bounds.width <= container.x + container.width + epsilon && bounds.y + bounds.height <= container.y + container.height + epsilon, `${label}: expected bounds inside container, received ${JSON.stringify( { bounds, container } )}` ); } function assertNoOverlappingBounds(entries, label) { for ( let leftIndex = 0; leftIndex < entries.length; leftIndex += 1 ) { for ( let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1 ) { const left = entries[leftIndex]; const right = entries[rightIndex]; assertFiniteBounds(left.bounds, `${label} ${left.id}`); assertFiniteBounds(right.bounds, `${label} ${right.id}`); const overlapWidth = Math.min( left.bounds.x + left.bounds.width, right.bounds.x + right.bounds.width ) - Math.max(left.bounds.x, right.bounds.x); const overlapHeight = Math.min( left.bounds.y + left.bounds.height, right.bounds.y + right.bounds.height ) - Math.max(left.bounds.y, right.bounds.y); assert( overlapWidth <= 0 || overlapHeight <= 0, `${label} "${left.id}" and "${right.id}" overlap: ${JSON.stringify( { left, right } )}` ); } } } async function capture(page, path) { await page.waitForTimeout(200); const loopSlept = await page.evaluate(() => { const loop = window.__HEROS_GAME__?.loop; if (!loop || typeof loop.sleep !== 'function') { return false; } loop.sleep(); return true; }); await page.waitForTimeout(40); try { await page.screenshot({ path, fullPage: true }); } finally { if (loopSlept) { await page.evaluate(() => window.__HEROS_GAME__?.loop.wake() ); await page.waitForTimeout(40); } } } function screenshotPath(renderer, stage) { return `dist/verification-xuzhou-stay-payoff-${renderer}-${stage}.png`; } function cliOption(name) { const prefix = `--${name}=`; const value = process.argv.find((argument) => argument.startsWith(prefix) ); return value?.slice(prefix.length); }