import { spawn } from 'node:child_process'; import { chromium } from 'playwright'; const targetUrl = process.env.VERIFY_URL ?? 'http://localhost:5173/'; let serverProcess; let browser; try { serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); await page.goto(targetUrl, { waitUntil: 'networkidle' }); await page.evaluate(() => window.localStorage.clear()); await page.reload({ waitUntil: 'networkidle' }); await page.waitForSelector('canvas'); await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined); await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined); await page.waitForTimeout(500); const debugBeforeBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); if (!debugBeforeBattle.includes('TitleScene')) { throw new Error(`Expected TitleScene before starting. Active scenes: ${debugBeforeBattle.join(', ')}`); } await page.mouse.click(962, 240); await page.waitForTimeout(250); for (let i = 0; i < 20; i += 1) { const isBattleActive = await page.evaluate(() => { const activeScenes = window.__HEROS_GAME__?.scene.getScenes(true) ?? []; return activeScenes.some((scene) => scene.scene.key === 'BattleScene'); }); if (isBattleActive) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(220); } await page.waitForFunction(() => { const activeScenes = window.__HEROS_GAME__?.scene.getScenes(true) ?? []; return activeScenes.some((scene) => scene.scene.key === 'BattleScene'); }); await page.keyboard.press('F9'); await page.waitForTimeout(100); await page.screenshot({ path: 'dist/verification-battle.png', fullPage: true }); const result = await page.evaluate(() => { const canvas = document.querySelector('canvas'); const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const battleState = window.__HEROS_DEBUG__?.battle(); return { activeScenes, battleState, canvasWidth: canvas?.width ?? 0, canvasHeight: canvas?.height ?? 0 }; }); if (result.canvasWidth !== 1280 || result.canvasHeight !== 720) { throw new Error(`Unexpected canvas size: ${result.canvasWidth}x${result.canvasHeight}`); } if (!result.activeScenes.includes('BattleScene')) { throw new Error(`BattleScene was not active. Active scenes: ${result.activeScenes.join(', ')}`); } if (!result.battleState || result.battleState.scene !== 'BattleScene') { throw new Error(`Debug battle state was not available: ${JSON.stringify(result.battleState)}`); } if (result.battleState.battleId !== 'first-battle-zhuo-commandery') { throw new Error(`Expected first battle scenario id: ${JSON.stringify(result.battleState)}`); } if (result.battleState.victoryConditionLabel !== '두령 한석 격파' || result.battleState.defeatConditionLabel !== '유비 퇴각') { throw new Error(`Expected first battle objective labels: ${JSON.stringify(result.battleState)}`); } const openingLeaderObjective = result.battleState.objectives.find((objective) => objective.id === 'leader'); if (!openingLeaderObjective || openingLeaderObjective.status !== 'active' || openingLeaderObjective.achieved) { throw new Error(`Expected live leader objective to start active: ${JSON.stringify(result.battleState.objectives)}`); } const actionTexturesLoaded = await page.evaluate(() => { const textures = window.__HEROS_GAME__?.textures; return [ 'unit-liu-bei-actions', 'unit-guan-yu-actions', 'unit-zhang-fei-actions', 'unit-rebel-actions', 'unit-rebel-archer-actions', 'unit-rebel-cavalry-actions', 'unit-rebel-leader-actions' ].every((key) => textures?.exists(key)); }); if (!actionTexturesLoaded) { throw new Error('Expected all unit action textures to be loaded.'); } const movementBlockingState = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const state = window.__HEROS_DEBUG__?.battle(); const liuBei = state?.units?.find((unit) => unit.id === 'liu-bei'); const guanYu = state?.units?.find((unit) => unit.id === 'guan-yu'); const enemy = state?.units?.find((unit) => unit.faction === 'enemy'); if (!scene || !liuBei || !guanYu || !enemy) { throw new Error('Expected battle scene and movement test units.'); } return { allyBlocksPath: scene.isMovementBlocked(liuBei, guanYu.x, guanYu.y), enemyBlocksPath: scene.isMovementBlocked(liuBei, enemy.x, enemy.y), allyStopTileReachable: scene.reachableTiles(liuBei).some((tile) => tile.x === guanYu.x && tile.y === guanYu.y) }; }); if ( movementBlockingState.allyBlocksPath || !movementBlockingState.enemyBlocksPath || movementBlockingState.allyStopTileReachable ) { throw new Error(`Expected allies to be passable path blockers only as stop tiles, and enemies to block movement: ${JSON.stringify(movementBlockingState)}`); } const liuBeiScreen = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const state = window.__HEROS_DEBUG__?.battle(); const liuBei = state?.units?.find((unit) => unit.id === 'liu-bei'); if (!scene || !liuBei) { throw new Error('Expected Liu Bei and battle scene for stand-still command test.'); } return { x: scene.tileCenterX(liuBei.x), y: scene.tileCenterY(liuBei.y) }; }); await page.mouse.click(liuBeiScreen.x, liuBeiScreen.y); await page.waitForTimeout(140); await page.mouse.click(liuBeiScreen.x, liuBeiScreen.y); await page.waitForTimeout(180); const standStillCommandState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); if ( standStillCommandState?.phase !== 'command' || standStillCommandState.selectedUnitId !== 'liu-bei' || standStillCommandState.pendingMove?.unitId !== 'liu-bei' || standStillCommandState.pendingMove.from.x !== standStillCommandState.pendingMove.to.x || standStillCommandState.pendingMove.from.y !== standStillCommandState.pendingMove.to.y ) { throw new Error(`Expected Liu Bei to open commands without moving: ${JSON.stringify(standStillCommandState)}`); } await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if (!scene) { return; } scene.selectedUnit = undefined; scene.pendingMove = undefined; scene.targetingAction = undefined; scene.selectedUsable = undefined; scene.phase = 'idle'; scene.clearMarkers(); scene.hideCommandMenu(); scene.hideMapMenu(); }); await page.waitForTimeout(700); await page.mouse.click(260, 300, { button: 'right' }); await page.waitForTimeout(120); await page.mouse.click(182, 196); await page.waitForTimeout(160); const threatState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); if (!threatState?.threatMarkerCount || threatState.threatMarkerCount <= 0) { throw new Error(`Expected enemy threat markers from map menu: ${JSON.stringify(threatState)}`); } await page.mouse.click(260, 300, { button: 'right' }); await page.waitForTimeout(120); await page.mouse.click(182, 230); await page.waitForTimeout(120); await page.mouse.click(400, 213); await page.waitForTimeout(180); const battleSlotSave = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:battle:first-battle-zhuo-commandery:slot-1'); return raw ? JSON.parse(raw) : undefined; }); if (!battleSlotSave || battleSlotSave.battleId !== 'first-battle-zhuo-commandery') { throw new Error(`Expected slot 1 battle save from save UI: ${JSON.stringify(battleSlotSave)}`); } await page.mouse.click(260, 300, { button: 'right' }); await page.waitForTimeout(120); await page.mouse.click(182, 264); await page.waitForTimeout(120); await page.mouse.click(400, 213); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.scene === 'BattleScene'); const cameraBeforeScroll = result.battleState.camera; if ( !cameraBeforeScroll || cameraBeforeScroll.mapWidth <= cameraBeforeScroll.visibleColumns || cameraBeforeScroll.mapHeight <= cameraBeforeScroll.visibleRows ) { throw new Error(`Expected a scrollable battle map: ${JSON.stringify(cameraBeforeScroll)}`); } await page.mouse.move(872, 360); await page.waitForTimeout(520); const cameraAfterEdgeScroll = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.camera); if (!cameraAfterEdgeScroll || cameraAfterEdgeScroll.x <= cameraBeforeScroll.x) { throw new Error(`Expected edge scrolling to move camera right: ${JSON.stringify({ cameraBeforeScroll, cameraAfterEdgeScroll })}`); } await page.mouse.click(1138, 553); await page.waitForTimeout(160); const cameraAfterMiniMapClick = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.camera); if ( !cameraAfterMiniMapClick || cameraAfterMiniMapClick.x < cameraAfterMiniMapClick.mapWidth - cameraAfterMiniMapClick.visibleColumns || cameraAfterMiniMapClick.y !== 0 ) { throw new Error(`Expected minimap click to move camera to the northeast: ${JSON.stringify(cameraAfterMiniMapClick)}`); } const readyUnits = result.battleState.units.filter((unit) => !unit.acted); if (!readyUnits.some((unit) => unit.animating && unit.animationKey?.includes('-idle-'))) { throw new Error(`Expected ready units to loop idle frames: ${JSON.stringify(readyUnits)}`); } await page.keyboard.press('F9'); await page.waitForTimeout(100); await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); const victoryState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const livingEnemiesAfterVictory = victoryState.units.filter((unit) => unit.faction === 'enemy' && unit.hp > 0); if (livingEnemiesAfterVictory.length > 0) { throw new Error(`Expected no living enemies after forced victory: ${JSON.stringify(livingEnemiesAfterVictory)}`); } const victoryLeaderObjective = victoryState.objectives.find((objective) => objective.id === 'leader'); if (!victoryLeaderObjective || victoryLeaderObjective.status !== 'done' || !victoryLeaderObjective.achieved) { throw new Error(`Expected leader objective to resolve after victory: ${JSON.stringify(victoryState.objectives)}`); } await page.screenshot({ path: 'dist/verification-battle-result-victory.png', fullPage: true }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); await page.screenshot({ path: 'dist/verification-camp.png', fullPage: true }); const campState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!campState || campState.scene !== 'CampScene' || campState.report?.rewardGold <= 0) { throw new Error(`Expected CampScene report after victory: ${JSON.stringify(campState)}`); } if ( campState.campBattleId !== 'first-battle-zhuo-commandery' || campState.availableDialogueIds?.length !== 3 || !campState.availableDialogueIds.every((id) => id.endsWith('first-battle')) ) { throw new Error(`Expected first camp to expose first-battle dialogue set: ${JSON.stringify(campState)}`); } await page.evaluate(() => { const campScene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const liuBei = campScene?.campaign?.roster?.find((unit) => unit.id === 'liu-bei'); if (!liuBei) { throw new Error('Expected Liu Bei in camp roster before supply verification.'); } liuBei.hp = Math.max(1, liuBei.maxHp - 10); campScene.report?.units?.forEach((unit) => { if (unit.id === 'liu-bei') { unit.hp = liuBei.hp; } }); campScene.render(); }); await page.mouse.click(888, 38); await page.waitForTimeout(160); await page.screenshot({ path: 'dist/verification-camp-supplies.png', fullPage: true }); await page.mouse.click(1120, 245); await page.waitForTimeout(260); const campStateAfterSupply = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const liuBeiAfterSupply = campStateAfterSupply?.campaign?.roster?.find((unit) => unit.id === 'liu-bei'); if (!liuBeiAfterSupply || liuBeiAfterSupply.hp !== liuBeiAfterSupply.maxHp) { throw new Error(`Expected bean supply to recover Liu Bei: ${JSON.stringify(campStateAfterSupply)}`); } if ((campStateAfterSupply.campaign?.inventory?.['콩'] ?? 0) !== 0) { throw new Error(`Expected bean supply to be consumed: ${JSON.stringify(campStateAfterSupply.campaign?.inventory)}`); } const goldBeforeMerchant = campStateAfterSupply.campaign?.gold ?? 0; await page.mouse.click(702, 386); await page.waitForTimeout(220); const campStateAfterMerchant = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( (campStateAfterMerchant.campaign?.inventory?.['콩'] ?? 0) !== 1 || campStateAfterMerchant.campaign?.gold !== goldBeforeMerchant - 40 ) { throw new Error(`Expected merchant purchase to add bean and spend gold: ${JSON.stringify({ goldBeforeMerchant, campStateAfterMerchant })}`); } await page.mouse.click(732, 38); await page.waitForTimeout(120); await page.mouse.click(974, 482); await page.waitForTimeout(260); const campStateAfterDialogue = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!campStateAfterDialogue.report?.completedCampDialogues?.length || campStateAfterDialogue.completedAvailableDialogues?.length !== 1) { throw new Error(`Expected camp dialogue to award bond exp: ${JSON.stringify(campStateAfterDialogue)}`); } const campaignSaveAfterDialogue = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:campaign-state'); return raw ? JSON.parse(raw) : undefined; }); if ( !campaignSaveAfterDialogue || campaignSaveAfterDialogue.step !== 'first-camp' || campaignSaveAfterDialogue.latestBattleId !== 'first-battle-zhuo-commandery' || !campaignSaveAfterDialogue.battleHistory?.['first-battle-zhuo-commandery'] || !campaignSaveAfterDialogue.completedCampDialogues?.length || Object.keys(campaignSaveAfterDialogue.inventory ?? {}).length === 0 ) { throw new Error(`Expected campaign save to persist camp progress: ${JSON.stringify(campaignSaveAfterDialogue)}`); } const campaignSlotAfterDialogue = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:campaign-state:slot-1'); return raw ? JSON.parse(raw) : undefined; }); if (!campaignSlotAfterDialogue || campaignSlotAfterDialogue.latestBattleId !== 'first-battle-zhuo-commandery') { throw new Error(`Expected campaign slot 1 to persist progress: ${JSON.stringify(campaignSlotAfterDialogue)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(180); const campStateWithSortiePrep = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!campStateWithSortiePrep?.sortieVisible) { throw new Error(`Expected sortie preparation overlay from next story button: ${JSON.stringify(campStateWithSortiePrep)}`); } assertSortieTacticalRoster(campStateWithSortiePrep, ['liu-bei', 'guan-yu', 'zhang-fei']); await page.screenshot({ path: 'dist/verification-camp-sortie.png', fullPage: true }); await page.mouse.click(922, 646); await page.waitForTimeout(140); const campStateAfterSortieClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (campStateAfterSortieClose?.sortieVisible) { throw new Error(`Expected sortie preparation overlay to close: ${JSON.stringify(campStateAfterSortieClose)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(120); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); for (let i = 0; i < 24; i += 1) { const isSecondBattleActive = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'second-battle-yellow-turban-pursuit'; }); if (isSecondBattleActive) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'second-battle-yellow-turban-pursuit' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-second-battle.png', fullPage: true }); const secondBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const secondEnemies = secondBattleState.units.filter((unit) => unit.faction === 'enemy'); const secondEnemyBehaviors = new Set(secondEnemies.map((unit) => unit.ai)); if ( secondBattleState.camera?.mapWidth !== 24 || secondBattleState.camera?.mapHeight !== 20 || secondEnemies.length < 10 || !secondEnemyBehaviors.has('aggressive') || !secondEnemyBehaviors.has('guard') || !secondEnemyBehaviors.has('hold') ) { throw new Error(`Expected expanded second battle map and mixed enemy AI: ${JSON.stringify(secondBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const campaignSaveAfterSecondBattle = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:campaign-state'); return raw ? JSON.parse(raw) : undefined; }); if ( !campaignSaveAfterSecondBattle || campaignSaveAfterSecondBattle.step !== 'second-camp' || campaignSaveAfterSecondBattle.latestBattleId !== 'second-battle-yellow-turban-pursuit' || !campaignSaveAfterSecondBattle.battleHistory?.['second-battle-yellow-turban-pursuit'] || !campaignSaveAfterSecondBattle.completedCampDialogues?.length ) { throw new Error(`Expected campaign save to persist second battle victory: ${JSON.stringify(campaignSaveAfterSecondBattle)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const secondCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!secondCampSortieState?.sortieVisible) { throw new Error(`Expected second camp sortie preparation overlay: ${JSON.stringify(secondCampSortieState)}`); } await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); for (let i = 0; i < 24; i += 1) { const isThirdBattleActive = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'third-battle-guangzong-road'; }); if (isThirdBattleActive) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'third-battle-guangzong-road' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-third-battle.png', fullPage: true }); const thirdBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const thirdEnemies = thirdBattleState.units.filter((unit) => unit.faction === 'enemy'); const thirdEnemyBehaviors = new Set(thirdEnemies.map((unit) => unit.ai)); if ( thirdBattleState.camera?.mapWidth !== 24 || thirdBattleState.camera?.mapHeight !== 20 || thirdBattleState.victoryConditionLabel !== '전령 마원 격파' || thirdEnemies.length < 10 || !thirdEnemyBehaviors.has('aggressive') || !thirdEnemyBehaviors.has('guard') || !thirdEnemyBehaviors.has('hold') ) { throw new Error(`Expected third battle map, objective, and mixed enemy AI: ${JSON.stringify(thirdBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const campaignSaveAfterThirdBattle = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:campaign-state'); return raw ? JSON.parse(raw) : undefined; }); if ( !campaignSaveAfterThirdBattle || campaignSaveAfterThirdBattle.step !== 'third-camp' || campaignSaveAfterThirdBattle.latestBattleId !== 'third-battle-guangzong-road' || !campaignSaveAfterThirdBattle.battleHistory?.['third-battle-guangzong-road'] ) { throw new Error(`Expected campaign save to persist third battle victory: ${JSON.stringify(campaignSaveAfterThirdBattle)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const thirdCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!thirdCampSortieState?.sortieVisible) { throw new Error(`Expected third camp sortie preparation overlay: ${JSON.stringify(thirdCampSortieState)}`); } await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); for (let i = 0; i < 26; i += 1) { const isFourthBattleActive = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'fourth-battle-guangzong-camp'; }); if (isFourthBattleActive) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'fourth-battle-guangzong-camp' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-fourth-battle.png', fullPage: true }); const fourthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const fourthEnemies = fourthBattleState.units.filter((unit) => unit.faction === 'enemy'); const fourthEnemyBehaviors = new Set(fourthEnemies.map((unit) => unit.ai)); if ( fourthBattleState.camera?.mapWidth !== 24 || fourthBattleState.camera?.mapHeight !== 20 || fourthBattleState.victoryConditionLabel !== '장각 격파' || fourthEnemies.length < 12 || !fourthEnemyBehaviors.has('aggressive') || !fourthEnemyBehaviors.has('guard') || !fourthEnemyBehaviors.has('hold') ) { throw new Error(`Expected fourth battle map, objective, and mixed enemy AI: ${JSON.stringify(fourthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const campaignSaveAfterFourthBattle = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:campaign-state'); return raw ? JSON.parse(raw) : undefined; }); if ( !campaignSaveAfterFourthBattle || campaignSaveAfterFourthBattle.step !== 'fourth-camp' || campaignSaveAfterFourthBattle.latestBattleId !== 'fourth-battle-guangzong-camp' || !campaignSaveAfterFourthBattle.battleHistory?.['fourth-battle-guangzong-camp'] ) { throw new Error(`Expected campaign save to persist fourth battle victory: ${JSON.stringify(campaignSaveAfterFourthBattle)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const fourthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!fourthCampSortieState?.sortieVisible) { throw new Error(`Expected fourth camp sortie preparation overlay: ${JSON.stringify(fourthCampSortieState)}`); } await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); for (let i = 0; i < 26; i += 1) { const isFifthBattleActive = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'fifth-battle-sishui-vanguard'; }); if (isFifthBattleActive) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'fifth-battle-sishui-vanguard' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-fifth-battle.png', fullPage: true }); const fifthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const fifthEnemies = fifthBattleState.units.filter((unit) => unit.faction === 'enemy'); const fifthEnemyBehaviors = new Set(fifthEnemies.map((unit) => unit.ai)); if ( fifthBattleState.camera?.mapWidth !== 24 || fifthBattleState.camera?.mapHeight !== 20 || fifthBattleState.victoryConditionLabel !== '호진 격파' || fifthEnemies.length < 10 || !fifthEnemyBehaviors.has('aggressive') || !fifthEnemyBehaviors.has('guard') || !fifthEnemyBehaviors.has('hold') ) { throw new Error(`Expected fifth battle map, objective, and mixed enemy AI: ${JSON.stringify(fifthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const campaignSaveAfterFifthBattle = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:campaign-state'); return raw ? JSON.parse(raw) : undefined; }); if ( !campaignSaveAfterFifthBattle || campaignSaveAfterFifthBattle.step !== 'fifth-camp' || campaignSaveAfterFifthBattle.latestBattleId !== 'fifth-battle-sishui-vanguard' || !campaignSaveAfterFifthBattle.battleHistory?.['fifth-battle-sishui-vanguard'] ) { throw new Error(`Expected campaign save to persist fifth battle victory: ${JSON.stringify(campaignSaveAfterFifthBattle)}`); } const fifthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( fifthCampState?.campBattleId !== 'fifth-battle-sishui-vanguard' || fifthCampState.campTitle !== '공손찬 진영 군영' || fifthCampState.availableDialogueIds?.length !== 3 || !fifthCampState.availableDialogueIds.every((id) => id.endsWith('sishui')) ) { throw new Error(`Expected fifth camp to expose Gongsun Zan/Sishui dialogue set: ${JSON.stringify(fifthCampState)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const fifthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!fifthCampSortieState?.sortieVisible) { throw new Error(`Expected fifth camp sortie preparation overlay: ${JSON.stringify(fifthCampSortieState)}`); } await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); for (let i = 0; i < 26; i += 1) { const isSixthBattleActive = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'sixth-battle-jieqiao-relief'; }); if (isSixthBattleActive) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'sixth-battle-jieqiao-relief' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-sixth-battle.png', fullPage: true }); const sixthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const sixthEnemies = sixthBattleState.units.filter((unit) => unit.faction === 'enemy'); const sixthEnemyBehaviors = new Set(sixthEnemies.map((unit) => unit.ai)); if ( sixthBattleState.camera?.mapWidth !== 24 || sixthBattleState.camera?.mapHeight !== 20 || sixthBattleState.victoryConditionLabel !== '곡의 격파' || sixthEnemies.length < 12 || !sixthEnemyBehaviors.has('aggressive') || !sixthEnemyBehaviors.has('guard') || !sixthEnemyBehaviors.has('hold') ) { throw new Error(`Expected sixth battle map, objective, and mixed enemy AI: ${JSON.stringify(sixthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const campaignSaveAfterSixthBattle = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:campaign-state'); return raw ? JSON.parse(raw) : undefined; }); if ( !campaignSaveAfterSixthBattle || campaignSaveAfterSixthBattle.step !== 'sixth-camp' || campaignSaveAfterSixthBattle.latestBattleId !== 'sixth-battle-jieqiao-relief' || !campaignSaveAfterSixthBattle.battleHistory?.['sixth-battle-jieqiao-relief'] ) { throw new Error(`Expected campaign save to persist sixth battle victory: ${JSON.stringify(campaignSaveAfterSixthBattle)}`); } const sixthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( sixthCampState?.campBattleId !== 'sixth-battle-jieqiao-relief' || sixthCampState.campTitle !== '서주 원군 준비 군영' || sixthCampState.availableDialogueIds?.length !== 3 || !sixthCampState.availableDialogueIds.every((id) => id.endsWith('jieqiao')) ) { throw new Error(`Expected sixth camp to expose Xu Province bridge dialogue set: ${JSON.stringify(sixthCampState)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const sixthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!sixthCampSortieState?.sortieVisible) { throw new Error(`Expected sixth camp sortie preparation overlay: ${JSON.stringify(sixthCampSortieState)}`); } if (!sixthCampSortieState.selectedSortieUnitIds?.includes('liu-bei') || sixthCampSortieState.selectedSortieUnitIds.length < 3) { throw new Error(`Expected sortie prep to default to the core brother roster: ${JSON.stringify(sixthCampSortieState)}`); } assertSortieTacticalRoster(sixthCampSortieState, ['liu-bei', 'guan-yu', 'zhang-fei']); await clickSortieRosterUnit(page, 'guan-yu'); await page.waitForTimeout(220); const sixthCampAfterSortieToggle = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !sixthCampAfterSortieToggle?.sortieVisible || sixthCampAfterSortieToggle.selectedSortieUnitIds?.includes('guan-yu') || !sixthCampAfterSortieToggle.selectedSortieUnitIds?.includes('liu-bei') || !sixthCampAfterSortieToggle.selectedSortieUnitIds?.includes('zhang-fei') ) { throw new Error(`Expected sortie selection toggle to bench Guan Yu while keeping Liu Bei/Zhang Fei: ${JSON.stringify(sixthCampAfterSortieToggle)}`); } await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-seventh-bridge-story.png', fullPage: true }); for (let i = 0; i < 30; i += 1) { const isSeventhBattleActive = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'seventh-battle-xuzhou-rescue'; }); if (isSeventhBattleActive) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'seventh-battle-xuzhou-rescue' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-seventh-battle.png', fullPage: true }); const seventhBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const seventhEnemies = seventhBattleState.units.filter((unit) => unit.faction === 'enemy'); const seventhAllies = seventhBattleState.units.filter((unit) => unit.faction === 'ally'); const seventhEnemyBehaviors = new Set(seventhEnemies.map((unit) => unit.ai)); if ( seventhBattleState.camera?.mapWidth !== 24 || seventhBattleState.camera?.mapHeight !== 20 || seventhBattleState.victoryConditionLabel !== '하후돈 격파' || seventhEnemies.length < 12 || !seventhEnemyBehaviors.has('aggressive') || !seventhEnemyBehaviors.has('guard') || !seventhEnemyBehaviors.has('hold') || seventhAllies.some((unit) => unit.id === 'guan-yu') || !seventhAllies.some((unit) => unit.id === 'liu-bei') || !seventhAllies.some((unit) => unit.id === 'zhang-fei') ) { throw new Error(`Expected seventh battle map, objective, mixed AI, and saved sortie selection: ${JSON.stringify(seventhBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const campaignSaveAfterSeventhBattle = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:campaign-state'); return raw ? JSON.parse(raw) : undefined; }); if ( !campaignSaveAfterSeventhBattle || campaignSaveAfterSeventhBattle.step !== 'seventh-camp' || campaignSaveAfterSeventhBattle.latestBattleId !== 'seventh-battle-xuzhou-rescue' || !campaignSaveAfterSeventhBattle.battleHistory?.['seventh-battle-xuzhou-rescue'] || !campaignSaveAfterSeventhBattle.roster?.some((unit) => unit.id === 'guan-yu') ) { throw new Error(`Expected campaign save to persist seventh battle victory and preserve benched roster: ${JSON.stringify(campaignSaveAfterSeventhBattle)}`); } const seventhCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( seventhCampState?.campBattleId !== 'seventh-battle-xuzhou-rescue' || seventhCampState.campTitle !== '서주 인수 준비 군영' || seventhCampState.availableDialogueIds?.length !== 5 || !seventhCampState.availableDialogueIds.every((id) => id.endsWith('xuzhou')) || !seventhCampState.campaign?.roster?.some((unit) => unit.id === 'guan-yu') || !seventhCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') || !seventhCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu') ) { throw new Error(`Expected seventh camp to expose Xu Province dialogue set, preserved roster, and new recruits: ${JSON.stringify(seventhCampState)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const seventhCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!seventhCampSortieState?.sortieVisible) { throw new Error(`Expected seventh camp story bridge overlay: ${JSON.stringify(seventhCampSortieState)}`); } await clickSortieRosterUnit(page, 'jian-yong'); await page.waitForTimeout(120); const seventhCampRecruitSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!seventhCampRecruitSortieState.selectedSortieUnitIds?.includes('jian-yong')) { throw new Error(`Expected recruit to be selectable in seventh camp sortie prep: ${JSON.stringify(seventhCampRecruitSortieState)}`); } assertSortieTacticalRoster(seventhCampRecruitSortieState, ['liu-bei', 'zhang-fei', 'jian-yong', 'mi-zhu']); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-xuzhou-entrust-story.png', fullPage: true }); for (let i = 0; i < 10; i += 1) { const enteredEighthBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'eighth-battle-xiaopei-supply-road'; }); if (enteredEighthBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'eighth-battle-xiaopei-supply-road' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-eighth-battle.png', fullPage: true }); const eighthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const eighthEnemies = eighthBattleState.units.filter((unit) => unit.faction === 'enemy'); const eighthAllies = eighthBattleState.units.filter((unit) => unit.faction === 'ally'); const eighthEnemyBehaviors = new Set(eighthEnemies.map((unit) => unit.ai)); if ( eighthBattleState.camera?.mapWidth !== 26 || eighthBattleState.camera?.mapHeight !== 22 || eighthBattleState.victoryConditionLabel !== '고순 격파' || eighthEnemies.length < 11 || !eighthEnemyBehaviors.has('aggressive') || !eighthEnemyBehaviors.has('guard') || !eighthEnemyBehaviors.has('hold') || eighthAllies.some((unit) => unit.id === 'guan-yu') || !eighthAllies.some((unit) => unit.id === 'liu-bei') || !eighthAllies.some((unit) => unit.id === 'zhang-fei') || !eighthAllies.some((unit) => unit.id === 'jian-yong') ) { throw new Error(`Expected eighth battle to use expanded map, mixed AI, and selected recruit sortie: ${JSON.stringify(eighthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const eighthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( eighthCampState?.campBattleId !== 'eighth-battle-xiaopei-supply-road' || eighthCampState.campTitle !== '소패 방위 군영' || eighthCampState.availableDialogueIds?.length !== 3 || !eighthCampState.availableDialogueIds.every((id) => id.endsWith('xiaopei')) || !eighthCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') || !eighthCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu') ) { throw new Error(`Expected eighth camp to expose Xiaopei dialogue set and preserve recruits: ${JSON.stringify(eighthCampState)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const eighthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!eighthCampSortieState?.sortieVisible) { throw new Error(`Expected eighth camp sortie prep overlay: ${JSON.stringify(eighthCampSortieState)}`); } if (!eighthCampSortieState.selectedSortieUnitIds?.includes('mi-zhu')) { await clickSortieRosterUnit(page, 'mi-zhu'); await page.waitForTimeout(120); } const eighthCampMiZhuSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!eighthCampMiZhuSortieState.selectedSortieUnitIds?.includes('mi-zhu')) { throw new Error(`Expected Mi Zhu to be selectable for ninth battle sortie: ${JSON.stringify(eighthCampMiZhuSortieState)}`); } assertSortieTacticalRoster(eighthCampMiZhuSortieState, ['liu-bei', 'zhang-fei', 'jian-yong', 'mi-zhu']); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-lubu-refuge-story.png', fullPage: true }); for (let i = 0; i < 10; i += 1) { const enteredNinthBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'ninth-battle-xuzhou-gate-night-raid'; }); if (enteredNinthBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'ninth-battle-xuzhou-gate-night-raid' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-ninth-battle.png', fullPage: true }); const ninthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const ninthEnemies = ninthBattleState.units.filter((unit) => unit.faction === 'enemy'); const ninthAllies = ninthBattleState.units.filter((unit) => unit.faction === 'ally'); const ninthEnemyBehaviors = new Set(ninthEnemies.map((unit) => unit.ai)); if ( ninthBattleState.camera?.mapWidth !== 26 || ninthBattleState.camera?.mapHeight !== 22 || ninthBattleState.victoryConditionLabel !== '조표 격파' || ninthEnemies.length < 10 || !ninthEnemyBehaviors.has('aggressive') || !ninthEnemyBehaviors.has('guard') || !ninthEnemyBehaviors.has('hold') || ninthAllies.some((unit) => unit.id === 'guan-yu') || !ninthAllies.some((unit) => unit.id === 'liu-bei') || !ninthAllies.some((unit) => unit.id === 'zhang-fei') || !ninthAllies.some((unit) => unit.id === 'jian-yong') || !ninthAllies.some((unit) => unit.id === 'mi-zhu') ) { throw new Error(`Expected ninth battle to use gate map, mixed AI, and selected recruit sortie: ${JSON.stringify(ninthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const ninthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( ninthCampState?.campBattleId !== 'ninth-battle-xuzhou-gate-night-raid' || ninthCampState.campTitle !== '서주 성문 군영' || ninthCampState.availableDialogueIds?.length !== 3 || !ninthCampState.availableDialogueIds.every((id) => id.endsWith('xuzhou-gate')) || !ninthCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') || !ninthCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu') ) { throw new Error(`Expected ninth camp to expose Xuzhou gate dialogue set and preserve recruits: ${JSON.stringify(ninthCampState)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const ninthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!ninthCampSortieState?.sortieVisible) { throw new Error(`Expected ninth camp sortie prep overlay: ${JSON.stringify(ninthCampSortieState)}`); } if (!ninthCampSortieState.selectedSortieUnitIds?.includes('guan-yu')) { await clickSortieRosterUnit(page, 'guan-yu'); await page.waitForTimeout(120); } const ninthCampGuanYuSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!ninthCampGuanYuSortieState.selectedSortieUnitIds?.includes('guan-yu')) { throw new Error(`Expected Guan Yu to be selectable again for Xuzhou breakout: ${JSON.stringify(ninthCampGuanYuSortieState)}`); } assertSortieTacticalRoster(ninthCampGuanYuSortieState, ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu']); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-xuzhou-loss-story.png', fullPage: true }); for (let i = 0; i < 14; i += 1) { const enteredTenthBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'tenth-battle-xuzhou-breakout'; }); if (enteredTenthBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'tenth-battle-xuzhou-breakout' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-tenth-battle.png', fullPage: true }); const tenthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const tenthEnemies = tenthBattleState.units.filter((unit) => unit.faction === 'enemy'); const tenthAllies = tenthBattleState.units.filter((unit) => unit.faction === 'ally'); const tenthEnemyBehaviors = new Set(tenthEnemies.map((unit) => unit.ai)); if ( tenthBattleState.camera?.mapWidth !== 28 || tenthBattleState.camera?.mapHeight !== 22 || tenthBattleState.victoryConditionLabel !== '송헌 격파' || tenthEnemies.length < 11 || !tenthEnemyBehaviors.has('aggressive') || !tenthEnemyBehaviors.has('guard') || !tenthEnemyBehaviors.has('hold') || !tenthAllies.some((unit) => unit.id === 'liu-bei') || !tenthAllies.some((unit) => unit.id === 'guan-yu') || !tenthAllies.some((unit) => unit.id === 'zhang-fei') || !tenthAllies.some((unit) => unit.id === 'jian-yong') || !tenthAllies.some((unit) => unit.id === 'mi-zhu') ) { throw new Error(`Expected tenth battle to use breakout map, mixed AI, and selected full Xu Province sortie: ${JSON.stringify(tenthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const tenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( tenthCampState?.campBattleId !== 'tenth-battle-xuzhou-breakout' || tenthCampState.campTitle !== '서주 상실 후 군영' || tenthCampState.availableDialogueIds?.length !== 3 || !tenthCampState.availableDialogueIds.every((id) => id.endsWith('xuzhou-loss')) || !tenthCampState.campaign?.roster?.some((unit) => unit.id === 'guan-yu') || !tenthCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') || !tenthCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu') ) { throw new Error(`Expected tenth camp to expose Xuzhou loss dialogue set and preserve full roster: ${JSON.stringify(tenthCampState)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const tenthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!tenthCampSortieState?.sortieVisible) { throw new Error(`Expected tenth camp sortie prep overlay: ${JSON.stringify(tenthCampSortieState)}`); } assertSortieTacticalRoster(tenthCampSortieState, ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu']); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-cao-cao-refuge-story.png', fullPage: true }); for (let i = 0; i < 16; i += 1) { const enteredEleventhBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'eleventh-battle-xudu-refuge-road'; }); if (enteredEleventhBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'eleventh-battle-xudu-refuge-road' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-eleventh-battle.png', fullPage: true }); const eleventhBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const eleventhEnemies = eleventhBattleState.units.filter((unit) => unit.faction === 'enemy'); const eleventhAllies = eleventhBattleState.units.filter((unit) => unit.faction === 'ally'); const eleventhEnemyBehaviors = new Set(eleventhEnemies.map((unit) => unit.ai)); if ( eleventhBattleState.camera?.mapWidth !== 28 || eleventhBattleState.camera?.mapHeight !== 22 || eleventhBattleState.victoryConditionLabel !== '기령 격파' || eleventhEnemies.length < 12 || !eleventhEnemyBehaviors.has('aggressive') || !eleventhEnemyBehaviors.has('guard') || !eleventhEnemyBehaviors.has('hold') || !eleventhAllies.some((unit) => unit.id === 'liu-bei') || !eleventhAllies.some((unit) => unit.id === 'guan-yu') || !eleventhAllies.some((unit) => unit.id === 'zhang-fei') || !eleventhAllies.some((unit) => unit.id === 'jian-yong') || !eleventhAllies.some((unit) => unit.id === 'mi-zhu') ) { throw new Error(`Expected eleventh battle to use Xudu road map, mixed AI, and selected Cao Cao refuge sortie: ${JSON.stringify(eleventhBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const eleventhCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( eleventhCampState?.campBattleId !== 'eleventh-battle-xudu-refuge-road' || eleventhCampState.campTitle !== '허도 의탁 군영' || eleventhCampState.availableDialogueIds?.length !== 3 || !eleventhCampState.availableDialogueIds.every((id) => id.endsWith('cao-cao-refuge')) || !eleventhCampState.campaign?.roster?.some((unit) => unit.id === 'guan-yu') || !eleventhCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') || !eleventhCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu') ) { throw new Error(`Expected eleventh camp to expose Cao Cao refuge dialogue set and preserve full roster: ${JSON.stringify(eleventhCampState)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const eleventhCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!eleventhCampSortieState?.sortieVisible || !eleventhCampSortieState.sortiePlan?.objectiveLine?.includes('하비 외곽전')) { throw new Error(`Expected eleventh camp sortie prep to target Xiapi outer siege: ${JSON.stringify(eleventhCampSortieState)}`); } assertSortieTacticalRoster(eleventhCampSortieState, ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu']); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-xiapi-siege-story.png', fullPage: true }); for (let i = 0; i < 18; i += 1) { const enteredTwelfthBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twelfth-battle-xiapi-outer-siege'; }); if (enteredTwelfthBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twelfth-battle-xiapi-outer-siege' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-twelfth-battle.png', fullPage: true }); const twelfthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const twelfthEnemies = twelfthBattleState.units.filter((unit) => unit.faction === 'enemy'); const twelfthAllies = twelfthBattleState.units.filter((unit) => unit.faction === 'ally'); const twelfthEnemyBehaviors = new Set(twelfthEnemies.map((unit) => unit.ai)); if ( twelfthBattleState.camera?.mapWidth !== 30 || twelfthBattleState.camera?.mapHeight !== 24 || twelfthBattleState.victoryConditionLabel !== '장료 격파' || twelfthEnemies.length < 13 || !twelfthEnemyBehaviors.has('aggressive') || !twelfthEnemyBehaviors.has('guard') || !twelfthEnemyBehaviors.has('hold') || !twelfthEnemies.some((unit) => unit.id === 'xiapi-strategist-chen-gong') || !twelfthAllies.some((unit) => unit.id === 'liu-bei') || !twelfthAllies.some((unit) => unit.id === 'guan-yu') || !twelfthAllies.some((unit) => unit.id === 'zhang-fei') || !twelfthAllies.some((unit) => unit.id === 'jian-yong') || !twelfthAllies.some((unit) => unit.id === 'mi-zhu') ) { throw new Error(`Expected twelfth battle to use Xiapi siege map, mixed AI, and selected full sortie: ${JSON.stringify(twelfthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const twelfthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( twelfthCampState?.campBattleId !== 'twelfth-battle-xiapi-outer-siege' || twelfthCampState.campTitle !== '하비 포위 군영' || twelfthCampState.availableDialogueIds?.length !== 3 || !twelfthCampState.availableDialogueIds.every((id) => id.endsWith('xiapi-outer')) || !twelfthCampState.campaign?.roster?.some((unit) => unit.id === 'guan-yu') || !twelfthCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') || !twelfthCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu') ) { throw new Error(`Expected twelfth camp to expose Xiapi siege dialogue set and preserve full roster: ${JSON.stringify(twelfthCampState)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const twelfthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !twelfthCampSortieState?.sortieVisible || !twelfthCampSortieState.sortiePlan?.objectiveLine?.includes('하비 결전') || twelfthCampSortieState.sortiePlan?.selectedRecruitedCount < 1 || !twelfthCampSortieState.sortiePlan?.recruitedLine?.includes('합류 무장') ) { throw new Error(`Expected twelfth camp sortie prep to target Xiapi final battle with recruit selection summary: ${JSON.stringify(twelfthCampSortieState)}`); } assertSortieTacticalRoster(twelfthCampSortieState, ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu']); if (!twelfthCampSortieState.sortieRoster.some((unit) => unit.id === 'jian-yong' && unit.recruited)) { throw new Error(`Expected recruit officers to be marked in sortie roster: ${JSON.stringify(twelfthCampSortieState.sortieRoster)}`); } await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-xiapi-final-story.png', fullPage: true }); for (let i = 0; i < 18; i += 1) { const enteredThirteenthBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'thirteenth-battle-xiapi-final'; }); if (enteredThirteenthBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'thirteenth-battle-xiapi-final' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-thirteenth-battle.png', fullPage: true }); const thirteenthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const thirteenthEnemies = thirteenthBattleState.units.filter((unit) => unit.faction === 'enemy'); const thirteenthAllies = thirteenthBattleState.units.filter((unit) => unit.faction === 'ally'); const thirteenthEnemyBehaviors = new Set(thirteenthEnemies.map((unit) => unit.ai)); if ( thirteenthBattleState.camera?.mapWidth !== 32 || thirteenthBattleState.camera?.mapHeight !== 24 || thirteenthBattleState.victoryConditionLabel !== '여포 격파' || thirteenthEnemies.length < 15 || !thirteenthEnemyBehaviors.has('aggressive') || !thirteenthEnemyBehaviors.has('guard') || !thirteenthEnemyBehaviors.has('hold') || !thirteenthEnemies.some((unit) => unit.id === 'xiapi-final-leader-lu-bu') || !thirteenthEnemies.some((unit) => unit.id === 'xiapi-final-strategist-chen-gong') || !thirteenthAllies.some((unit) => unit.id === 'liu-bei') || !thirteenthAllies.some((unit) => unit.id === 'guan-yu') || !thirteenthAllies.some((unit) => unit.id === 'zhang-fei') || !thirteenthAllies.some((unit) => unit.id === 'jian-yong') || !thirteenthAllies.some((unit) => unit.id === 'mi-zhu') ) { throw new Error(`Expected thirteenth battle to use Xiapi final map, Lu Bu objective, mixed AI, and selected recruit sortie: ${JSON.stringify(thirteenthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const thirteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( thirteenthCampState?.campBattleId !== 'thirteenth-battle-xiapi-final' || thirteenthCampState.campTitle !== '하비 결전 후 군영' || thirteenthCampState.availableDialogueIds?.length !== 3 || !thirteenthCampState.availableDialogueIds.every((id) => id.endsWith('lu-bu-fall')) || !thirteenthCampState.campaign?.roster?.some((unit) => unit.id === 'guan-yu') || !thirteenthCampState.campaign?.roster?.some((unit) => unit.id === 'jian-yong') || !thirteenthCampState.campaign?.roster?.some((unit) => unit.id === 'mi-zhu') || !thirteenthCampState.campaign?.roster?.some((unit) => unit.id === 'sun-qian') ) { throw new Error(`Expected thirteenth camp to expose Lu Bu fall dialogue set, recruit Sun Qian, and preserve full roster: ${JSON.stringify(thirteenthCampState)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const thirteenthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !thirteenthCampSortieState?.sortieVisible || !thirteenthCampSortieState.sortiePlan?.objectiveLine?.includes('서주 재기') || !thirteenthCampSortieState.sortieRoster?.some((unit) => unit.id === 'sun-qian' && unit.recruited && unit.recommended) ) { throw new Error(`Expected thirteenth camp sortie prep to target Cao break battle with Sun Qian as a recruit option: ${JSON.stringify(thirteenthCampSortieState)}`); } assertSortieTacticalRoster(thirteenthCampSortieState, ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian']); await clickSortieRosterUnit(page, 'sun-qian'); const thirteenthCampSunQianSelectedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!thirteenthCampSunQianSelectedState.sortieRoster?.some((unit) => unit.id === 'sun-qian' && unit.selected)) { throw new Error(`Expected Sun Qian to be selectable for the fourteenth battle sortie: ${JSON.stringify(thirteenthCampSunQianSelectedState)}`); } await page.screenshot({ path: 'dist/verification-cao-break-sortie.png', fullPage: true }); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-cao-break-story.png', fullPage: true }); for (let i = 0; i < 18; i += 1) { const enteredFourteenthBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'fourteenth-battle-cao-break'; }); if (enteredFourteenthBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'fourteenth-battle-cao-break' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-fourteenth-battle.png', fullPage: true }); const fourteenthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const fourteenthEnemies = fourteenthBattleState.units.filter((unit) => unit.faction === 'enemy'); const fourteenthAllies = fourteenthBattleState.units.filter((unit) => unit.faction === 'ally'); const fourteenthEnemyBehaviors = new Set(fourteenthEnemies.map((unit) => unit.ai)); if ( fourteenthBattleState.camera?.mapWidth !== 32 || fourteenthBattleState.camera?.mapHeight !== 24 || fourteenthBattleState.victoryConditionLabel !== '차주 격파' || fourteenthEnemies.length < 15 || !fourteenthEnemyBehaviors.has('aggressive') || !fourteenthEnemyBehaviors.has('guard') || !fourteenthEnemyBehaviors.has('hold') || !fourteenthEnemies.some((unit) => unit.id === 'cao-break-leader-che-zhou') || !fourteenthAllies.some((unit) => unit.id === 'sun-qian') ) { throw new Error(`Expected fourteenth battle to use Cao break map, Che Zhou objective, mixed AI, and selected Sun Qian sortie: ${JSON.stringify(fourteenthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const fourteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( fourteenthCampState?.campBattleId !== 'fourteenth-battle-cao-break' || fourteenthCampState.campTitle !== '서주 재기 후 군영' || fourteenthCampState.availableDialogueIds?.length !== 3 || !fourteenthCampState.availableDialogueIds.every((id) => id.endsWith('cao-break')) || !fourteenthCampState.campaign?.roster?.some((unit) => unit.id === 'sun-qian') ) { throw new Error(`Expected fourteenth camp to expose Cao break dialogue set and preserve Sun Qian in roster: ${JSON.stringify(fourteenthCampState)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const fourteenthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !fourteenthCampSortieState?.sortieVisible || !fourteenthCampSortieState.sortiePlan?.objectiveLine?.includes('원소 의탁로') || !fourteenthCampSortieState.sortieRoster?.some((unit) => unit.id === 'sun-qian' && unit.recruited) ) { throw new Error(`Expected fourteenth camp sortie prep to target Yuan Shao refuge road with expanded roster: ${JSON.stringify(fourteenthCampSortieState)}`); } assertSortieTacticalRoster(fourteenthCampSortieState, ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian']); await page.screenshot({ path: 'dist/verification-yuan-refuge-sortie.png', fullPage: true }); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-yuan-refuge-story.png', fullPage: true }); for (let i = 0; i < 18; i += 1) { const enteredFifteenthBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'fifteenth-battle-yuan-refuge-road'; }); if (enteredFifteenthBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'fifteenth-battle-yuan-refuge-road' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-fifteenth-battle.png', fullPage: true }); const fifteenthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const fifteenthEnemies = fifteenthBattleState.units.filter((unit) => unit.faction === 'enemy'); const fifteenthAllies = fifteenthBattleState.units.filter((unit) => unit.faction === 'ally'); const fifteenthEnemyBehaviors = new Set(fifteenthEnemies.map((unit) => unit.ai)); if ( fifteenthBattleState.camera?.mapWidth !== 34 || fifteenthBattleState.camera?.mapHeight !== 24 || fifteenthBattleState.victoryConditionLabel !== '채양 격파' || fifteenthEnemies.length < 16 || !fifteenthEnemyBehaviors.has('aggressive') || !fifteenthEnemyBehaviors.has('guard') || !fifteenthEnemyBehaviors.has('hold') || !fifteenthEnemies.some((unit) => unit.id === 'yuan-refuge-leader-cai-yang') || !fifteenthAllies.some((unit) => unit.id === 'sun-qian') ) { throw new Error(`Expected fifteenth battle to use Yuan refuge map, Cai Yang objective, mixed AI, and expanded sortie: ${JSON.stringify(fifteenthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const fifteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( fifteenthCampState?.campBattleId !== 'fifteenth-battle-yuan-refuge-road' || fifteenthCampState.campTitle !== '원소 의탁 후 군영' || fifteenthCampState.availableDialogueIds?.length !== 3 || !fifteenthCampState.availableDialogueIds.every((id) => id.endsWith('yuan-refuge')) || !fifteenthCampState.campaign?.roster?.some((unit) => unit.id === 'sun-qian') || !fifteenthCampState.campaign?.roster?.some((unit) => unit.id === 'zhao-yun') ) { throw new Error(`Expected fifteenth camp to expose Yuan refuge dialogue set, preserve expanded roster, and recruit Zhao Yun: ${JSON.stringify(fifteenthCampState)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const fifteenthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !fifteenthCampSortieState?.sortieVisible || !fifteenthCampSortieState.sortiePlan?.objectiveLine?.includes('유표 의탁로') || !fifteenthCampSortieState.sortieRoster?.some((unit) => unit.id === 'zhao-yun' && unit.recruited && unit.recommended) || fifteenthCampSortieState.sortieRoster?.length < 7 || fifteenthCampSortieState.sortiePlan?.maxCount !== 6 ) { throw new Error(`Expected fifteenth camp sortie prep to target Liu Biao refuge road with Zhao Yun as a selectable recruit: ${JSON.stringify(fifteenthCampSortieState)}`); } assertSortieTacticalRoster(fifteenthCampSortieState, [ 'liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian', 'zhao-yun' ]); if (fifteenthCampSortieState.sortieRoster?.some((unit) => unit.id === 'zhao-yun' && unit.selected)) { await clickSortieRosterUnit(page, 'zhao-yun'); } const fifteenthCampBeforeSwapState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (fifteenthCampBeforeSwapState.sortieRoster?.some((unit) => unit.id === 'jian-yong' && unit.selected)) { await clickSortieRosterUnit(page, 'jian-yong'); } await clickSortieRosterUnit(page, 'zhao-yun'); const fifteenthCampZhaoYunSelectedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !fifteenthCampZhaoYunSelectedState.sortieRoster?.some((unit) => unit.id === 'zhao-yun' && unit.selected) || fifteenthCampZhaoYunSelectedState.sortieRoster?.some((unit) => unit.id === 'jian-yong' && unit.selected) || fifteenthCampZhaoYunSelectedState.sortiePlan?.selectedCount !== 6 ) { throw new Error(`Expected Zhao Yun to replace Jian Yong in sixteenth battle sortie: ${JSON.stringify(fifteenthCampZhaoYunSelectedState)}`); } await page.screenshot({ path: 'dist/verification-liu-biao-sortie.png', fullPage: true }); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-liu-biao-story.png', fullPage: true }); for (let i = 0; i < 22; i += 1) { const enteredSixteenthBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'sixteenth-battle-liu-biao-refuge'; }); if (enteredSixteenthBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'sixteenth-battle-liu-biao-refuge' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-sixteenth-battle.png', fullPage: true }); const sixteenthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const sixteenthEnemies = sixteenthBattleState.units.filter((unit) => unit.faction === 'enemy'); const sixteenthAllies = sixteenthBattleState.units.filter((unit) => unit.faction === 'ally'); const sixteenthEnemyBehaviors = new Set(sixteenthEnemies.map((unit) => unit.ai)); if ( sixteenthBattleState.camera?.mapWidth !== 34 || sixteenthBattleState.camera?.mapHeight !== 26 || sixteenthBattleState.victoryConditionLabel !== '조인 격파' || sixteenthEnemies.length < 18 || !sixteenthEnemyBehaviors.has('aggressive') || !sixteenthEnemyBehaviors.has('guard') || !sixteenthEnemyBehaviors.has('hold') || !sixteenthEnemies.some((unit) => unit.id === 'liu-biao-road-leader-cao-ren') || !sixteenthAllies.some((unit) => unit.id === 'zhao-yun') || sixteenthAllies.some((unit) => unit.id === 'jian-yong') ) { throw new Error(`Expected sixteenth battle to use Liu Biao refuge map, Cao Ren objective, mixed AI, and selected Zhao Yun sortie: ${JSON.stringify(sixteenthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const sixteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( sixteenthCampState?.campBattleId !== 'sixteenth-battle-liu-biao-refuge' || sixteenthCampState.campTitle !== '형주 의탁 후 군영' || sixteenthCampState.availableDialogueIds?.length !== 3 || !sixteenthCampState.availableDialogueIds.every((id) => id.endsWith('liu-biao-refuge')) || sixteenthCampState.availableVisitIds?.length !== 3 || !sixteenthCampState.campaign?.roster?.some((unit) => unit.id === 'zhao-yun') ) { throw new Error(`Expected sixteenth camp to expose Liu Biao refuge dialogue/visit sets and preserve Zhao Yun in roster: ${JSON.stringify(sixteenthCampState)}`); } await page.mouse.click(810, 38); await page.waitForTimeout(160); const sixteenthVisitTabState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( sixteenthVisitTabState?.activeTab !== 'visit' || !sixteenthVisitTabState.availableVisitIds?.includes('jingzhou-scholar-rumors') || sixteenthVisitTabState.completedAvailableVisits?.length !== 0 ) { throw new Error(`Expected Liu Biao camp visit tab to expose local visit choices: ${JSON.stringify(sixteenthVisitTabState)}`); } await page.mouse.click(974, 436); await page.waitForTimeout(260); const sixteenthVisitRewardState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !sixteenthVisitRewardState.completedAvailableVisits?.includes('jingzhou-scholar-rumors') || !sixteenthVisitRewardState.campaign?.completedCampVisits?.includes('jingzhou-scholar-rumors') || (sixteenthVisitRewardState.campaign?.inventory?.['와룡 소문'] ?? 0) < 1 ) { throw new Error(`Expected completed visit to persist and award Wolong clue inventory: ${JSON.stringify(sixteenthVisitRewardState)}`); } await page.screenshot({ path: 'dist/verification-liu-biao-visits.png', fullPage: true }); await page.mouse.click(1120, 38); await page.waitForTimeout(160); const sixteenthCampWolongSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !sixteenthCampWolongSortieState?.sortieVisible || !sixteenthCampWolongSortieState.sortiePlan?.objectiveLine?.includes('융중 방문로') || !sixteenthCampWolongSortieState.sortieRoster?.some((unit) => unit.id === 'zhao-yun' && unit.recruited && unit.recommended) || !sixteenthCampWolongSortieState.sortieRoster?.some((unit) => unit.id === 'sun-qian' && unit.recruited && unit.recommended) || sixteenthCampWolongSortieState.sortieRoster?.length < 7 || sixteenthCampWolongSortieState.sortiePlan?.maxCount !== 6 ) { throw new Error(`Expected sixteenth camp sortie prep to target Wolong visit road with expanded selectable roster: ${JSON.stringify(sixteenthCampWolongSortieState)}`); } assertSortieTacticalRoster(sixteenthCampWolongSortieState, [ 'liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian', 'zhao-yun' ]); await page.screenshot({ path: 'dist/verification-wolong-sortie.png', fullPage: true }); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-wolong-story.png', fullPage: true }); for (let i = 0; i < 24; i += 1) { const enteredSeventeenthBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'seventeenth-battle-wolong-visit-road'; }); if (enteredSeventeenthBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'seventeenth-battle-wolong-visit-road' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-seventeenth-battle.png', fullPage: true }); const seventeenthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const seventeenthEnemies = seventeenthBattleState.units.filter((unit) => unit.faction === 'enemy'); const seventeenthAllies = seventeenthBattleState.units.filter((unit) => unit.faction === 'ally'); const seventeenthEnemyBehaviors = new Set(seventeenthEnemies.map((unit) => unit.ai)); if ( seventeenthBattleState.camera?.mapWidth !== 34 || seventeenthBattleState.camera?.mapHeight !== 26 || seventeenthBattleState.victoryConditionLabel !== '채순 격파' || seventeenthEnemies.length < 15 || !seventeenthEnemyBehaviors.has('aggressive') || !seventeenthEnemyBehaviors.has('guard') || !seventeenthEnemyBehaviors.has('hold') || !seventeenthEnemies.some((unit) => unit.id === 'wolong-road-leader-cai-xun') || !seventeenthAllies.some((unit) => unit.id === 'zhao-yun') || seventeenthAllies.some((unit) => unit.id === 'zhuge-liang') ) { throw new Error(`Expected seventeenth battle to use Wolong visit map, Cai Xun objective, mixed AI, and pre-Zhuge sortie: ${JSON.stringify(seventeenthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const seventeenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( seventeenthCampState?.campBattleId !== 'seventeenth-battle-wolong-visit-road' || seventeenthCampState.campTitle !== '와룡 출려 후 군영' || seventeenthCampState.availableDialogueIds?.length !== 3 || !seventeenthCampState.availableDialogueIds.every((id) => id.endsWith('wolong-visit')) || seventeenthCampState.availableVisitIds?.length !== 2 || !seventeenthCampState.campaign?.roster?.some((unit) => unit.id === 'zhuge-liang') || !seventeenthCampState.report?.bonds?.some((bond) => bond.id === 'liu-bei__zhuge-liang') || !seventeenthCampState.report?.bonds?.some((bond) => bond.id === 'guan-yu__zhuge-liang') ) { throw new Error(`Expected seventeenth camp to recruit Zhuge Liang and expose Wolong dialogue/visit sets: ${JSON.stringify(seventeenthCampState)}`); } await page.screenshot({ path: 'dist/verification-zhuge-recruit-camp.png', fullPage: true }); await page.mouse.click(966, 38); await page.waitForTimeout(180); const progressTabState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( progressTabState?.activeTab !== 'progress' || progressTabState.campaignProgress?.completedKnown !== 17 || progressTabState.campaignProgress?.totalKnown !== 26 || progressTabState.campaignProgress?.activeChapter?.title !== '적벽대전' || progressTabState.campaignProgress?.latestBattleTitle !== '융중 방문로' || progressTabState.campaignProgress?.nextBattleTitle !== '박망파 매복전' ) { throw new Error(`Expected progress tab to summarize Wolong completion and point toward Bowang ambush: ${JSON.stringify(progressTabState?.campaignProgress)}`); } await page.screenshot({ path: 'dist/verification-campaign-progress.png', fullPage: true }); await page.mouse.click(1120, 38); await page.waitForTimeout(160); const seventeenthCampBowangSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !seventeenthCampBowangSortieState?.sortieVisible || !seventeenthCampBowangSortieState.sortiePlan?.objectiveLine?.includes('박망파 매복전') || !seventeenthCampBowangSortieState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.recruited && unit.recommended) || seventeenthCampBowangSortieState.sortieRoster?.length < 8 || seventeenthCampBowangSortieState.sortiePlan?.maxCount !== 6 ) { throw new Error(`Expected seventeenth camp sortie prep to target Bowang ambush with Zhuge Liang as a selectable recruit: ${JSON.stringify(seventeenthCampBowangSortieState)}`); } assertSortieTacticalRoster(seventeenthCampBowangSortieState, [ 'liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian', 'zhao-yun', 'zhuge-liang' ]); if (!seventeenthCampBowangSortieState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.selected)) { const selectedReserveCandidate = ['jian-yong', 'mi-zhu'].find((unitId) => seventeenthCampBowangSortieState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected) ); if (selectedReserveCandidate) { await clickSortieRosterUnit(page, selectedReserveCandidate); } await clickSortieRosterUnit(page, 'zhuge-liang'); } const bowangSortieWithZhugeState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !bowangSortieWithZhugeState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.selected) || bowangSortieWithZhugeState.sortiePlan?.selectedCount !== 6 || bowangSortieWithZhugeState.sortiePlan?.recommendedSelectedCount < 4 ) { throw new Error(`Expected Zhuge Liang to be selectable and deployed for Bowang ambush: ${JSON.stringify(bowangSortieWithZhugeState)}`); } await page.screenshot({ path: 'dist/verification-bowang-sortie.png', fullPage: true }); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-bowang-story.png', fullPage: true }); for (let i = 0; i < 26; i += 1) { const enteredEighteenthBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'eighteenth-battle-bowang-ambush'; }); if (enteredEighteenthBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'eighteenth-battle-bowang-ambush' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-eighteenth-battle.png', fullPage: true }); const eighteenthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const eighteenthEnemies = eighteenthBattleState.units.filter((unit) => unit.faction === 'enemy'); const eighteenthAllies = eighteenthBattleState.units.filter((unit) => unit.faction === 'ally'); const eighteenthEnemyBehaviors = new Set(eighteenthEnemies.map((unit) => unit.ai)); const treasureLabels = new Set( eighteenthBattleState.treasureEffectPreviews?.flatMap((preview) => preview.equipmentEffectLabels ?? []) ?? [] ); if ( eighteenthBattleState.camera?.mapWidth !== 36 || eighteenthBattleState.camera?.mapHeight !== 26 || eighteenthBattleState.victoryConditionLabel !== '하후돈 퇴각' || eighteenthEnemies.length < 16 || !eighteenthEnemyBehaviors.has('aggressive') || !eighteenthEnemyBehaviors.has('guard') || !eighteenthEnemyBehaviors.has('hold') || !eighteenthEnemies.some((unit) => unit.id === 'bowang-leader-xiahou-dun') || !eighteenthAllies.some((unit) => unit.id === 'zhuge-liang') || !treasureLabels.has('자웅일대검 공명') || (!treasureLabels.has('청룡언월도 무력 우위') && !treasureLabels.has('청룡언월도 대도')) || !treasureLabels.has('장팔사모 치명') || !treasureLabels.has('백우선 책략') ) { throw new Error(`Expected eighteenth battle to use Bowang map, Xiahou Dun objective, mixed AI, deployed Zhuge Liang, and treasure equipment effects: ${JSON.stringify(eighteenthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const eighteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( eighteenthCampState?.campBattleId !== 'eighteenth-battle-bowang-ambush' || eighteenthCampState.campTitle !== '박망파 승전 후 군영' || eighteenthCampState.availableDialogueIds?.length !== 3 || !eighteenthCampState.availableDialogueIds.every((id) => id.endsWith('bowang-ambush')) || eighteenthCampState.availableVisitIds?.length !== 2 || !eighteenthCampState.report?.bonds?.some((bond) => bond.id === 'zhang-fei__zhuge-liang') || !eighteenthCampState.report?.bonds?.some((bond) => bond.id === 'sun-qian__zhuge-liang') || eighteenthCampState.rosterCollection?.total < 8 || eighteenthCampState.rosterCollection?.reserveTrainingCount < 2 || eighteenthCampState.rosterCollection?.reserveTrainingExp < 12 || !eighteenthCampState.reserveTrainingAwards?.some((entry) => entry.unitId === 'jian-yong' && entry.expGained === 6) || !eighteenthCampState.reserveTrainingAwards?.every((entry) => entry.expGained === 6 && entry.equipmentExpGained === 2) || !eighteenthCampState.reserveTrainingAwards?.some((entry) => ['mi-zhu', 'sun-qian'].includes(entry.unitId)) ) { throw new Error(`Expected eighteenth camp to expose Bowang dialogue/visit sets, Zhuge bonds, and reserve training growth: ${JSON.stringify(eighteenthCampState)}`); } await page.screenshot({ path: 'dist/verification-bowang-camp.png', fullPage: true }); await page.mouse.click(654, 38); await page.waitForTimeout(160); await page.mouse.click(180, 315); await page.waitForTimeout(160); const bowangRosterState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( bowangRosterState?.activeTab !== 'status' || bowangRosterState.selectedUnitId !== 'jian-yong' || bowangRosterState.rosterCollection?.reserveCount < 2 || !bowangRosterState.reserveTrainingAwards?.some((entry) => entry.unitId === 'jian-yong' && entry.expGained === 6) ) { throw new Error(`Expected status tab to expose officer roster reserve growth details: ${JSON.stringify(bowangRosterState)}`); } await page.screenshot({ path: 'dist/verification-bowang-roster.png', fullPage: true }); await page.mouse.click(966, 38); await page.waitForTimeout(180); const postBowangProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( postBowangProgressState?.activeTab !== 'progress' || postBowangProgressState.campaignProgress?.completedKnown !== 18 || postBowangProgressState.campaignProgress?.totalKnown !== 26 || postBowangProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || postBowangProgressState.campaignProgress?.latestBattleTitle !== '박망파 매복전' || postBowangProgressState.campaignProgress?.nextBattleTitle !== '장판파 피난로' ) { throw new Error(`Expected post-Bowang progress tab to point toward Changban refuge route: ${JSON.stringify(postBowangProgressState?.campaignProgress)}`); } await page.screenshot({ path: 'dist/verification-post-bowang-progress.png', fullPage: true }); await page.mouse.click(1120, 38); await page.waitForTimeout(180); const changbanSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !changbanSortieState?.sortieVisible || !changbanSortieState.sortiePlan?.objectiveLine?.includes('장판파 피난로') || !changbanSortieState.sortieRoster?.some((unit) => unit.id === 'zhao-yun' && unit.recruited && unit.recommended) || !changbanSortieState.sortieRoster?.some((unit) => unit.id === 'zhang-fei' && unit.recommended) || !changbanSortieState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.recruited && unit.recommended) || changbanSortieState.sortieRoster?.length < 8 || changbanSortieState.sortiePlan?.maxCount !== 6 ) { throw new Error(`Expected eighteenth camp sortie prep to target Changban with Zhao Yun, Zhang Fei, and Zhuge Liang recommendations: ${JSON.stringify(changbanSortieState)}`); } assertSortieTacticalRoster(changbanSortieState, [ 'liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian', 'zhao-yun', 'zhuge-liang' ]); const changbanPriorityUnits = ['zhao-yun', 'zhang-fei', 'zhuge-liang']; for (const unitId of changbanPriorityUnits) { const currentSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!currentSortieState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected)) { const removable = currentSortieState.sortieRoster?.find( (unit) => unit.selected && unit.id !== 'liu-bei' && !changbanPriorityUnits.includes(unit.id) ); if (removable) { await clickSortieRosterUnit(page, removable.id); } await clickSortieRosterUnit(page, unitId); } } const changbanSortieReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !changbanPriorityUnits.every((unitId) => changbanSortieReadyState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected) ) || changbanSortieReadyState.sortiePlan?.selectedCount !== 6 || changbanSortieReadyState.sortiePlan?.recommendedSelectedCount < 5 ) { throw new Error(`Expected Changban sortie to deploy priority officers and preserve six-officer choice pressure: ${JSON.stringify(changbanSortieReadyState)}`); } await page.screenshot({ path: 'dist/verification-changban-sortie.png', fullPage: true }); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-changban-story.png', fullPage: true }); for (let i = 0; i < 30; i += 1) { const enteredNineteenthBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'nineteenth-battle-changban-refuge'; }); if (enteredNineteenthBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'nineteenth-battle-changban-refuge' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-nineteenth-battle.png', fullPage: true }); const nineteenthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const nineteenthEnemies = nineteenthBattleState.units.filter((unit) => unit.faction === 'enemy'); const nineteenthAllies = nineteenthBattleState.units.filter((unit) => unit.faction === 'ally'); const nineteenthEnemyBehaviors = new Set(nineteenthEnemies.map((unit) => unit.ai)); if ( nineteenthBattleState.camera?.mapWidth !== 38 || nineteenthBattleState.camera?.mapHeight !== 28 || nineteenthBattleState.victoryConditionLabel !== '조순 퇴각' || nineteenthEnemies.length < 17 || !nineteenthEnemyBehaviors.has('aggressive') || !nineteenthEnemyBehaviors.has('guard') || !nineteenthEnemyBehaviors.has('hold') || !nineteenthEnemies.some((unit) => unit.id === 'changban-leader-cao-chun') || !nineteenthAllies.some((unit) => unit.id === 'zhao-yun') || !nineteenthAllies.some((unit) => unit.id === 'zhuge-liang') ) { throw new Error(`Expected nineteenth battle to use Changban map, Cao Chun objective, mixed AI, Zhao Yun, and Zhuge Liang: ${JSON.stringify(nineteenthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const nineteenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( nineteenthCampState?.campBattleId !== 'nineteenth-battle-changban-refuge' || nineteenthCampState.campTitle !== '장판파 돌파 후 군영' || nineteenthCampState.availableDialogueIds?.length !== 3 || !nineteenthCampState.availableDialogueIds.every((id) => id.endsWith('changban-refuge')) || nineteenthCampState.availableVisitIds?.length !== 2 || !nineteenthCampState.report?.bonds?.some((bond) => bond.id === 'liu-bei__zhao-yun') || !nineteenthCampState.report?.bonds?.some((bond) => bond.id === 'zhang-fei__zhuge-liang') || nineteenthCampState.rosterCollection?.total < 8 || nineteenthCampState.rosterCollection?.reserveTrainingCount < 2 || nineteenthCampState.rosterCollection?.reserveTrainingExp < 12 ) { throw new Error(`Expected nineteenth camp to expose Changban dialogue/visit sets, Zhao Yun bonds, and reserve growth: ${JSON.stringify(nineteenthCampState)}`); } await page.screenshot({ path: 'dist/verification-changban-camp.png', fullPage: true }); await page.mouse.click(966, 38); await page.waitForTimeout(180); const postChangbanProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( postChangbanProgressState?.activeTab !== 'progress' || postChangbanProgressState.campaignProgress?.completedKnown !== 19 || postChangbanProgressState.campaignProgress?.totalKnown !== 26 || postChangbanProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || postChangbanProgressState.campaignProgress?.latestBattleTitle !== '장판파 피난로' || postChangbanProgressState.campaignProgress?.nextBattleTitle !== '강동 사절로' ) { throw new Error(`Expected post-Changban progress tab to point toward Jiangdong envoy route: ${JSON.stringify(postChangbanProgressState?.campaignProgress)}`); } await page.screenshot({ path: 'dist/verification-post-changban-progress.png', fullPage: true }); await page.mouse.click(1120, 38); await page.waitForTimeout(180); const jiangdongSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !jiangdongSortieState?.sortieVisible || !jiangdongSortieState.sortiePlan?.objectiveLine?.includes('강동 사절로') || !jiangdongSortieState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.recruited && unit.recommended) || !jiangdongSortieState.sortieRoster?.some((unit) => unit.id === 'sun-qian' && unit.recruited && unit.recommended) || !jiangdongSortieState.sortieRoster?.some((unit) => unit.id === 'zhao-yun' && unit.recruited && unit.recommended) || jiangdongSortieState.sortieRoster?.length < 8 || jiangdongSortieState.sortiePlan?.maxCount !== 6 ) { throw new Error(`Expected nineteenth camp sortie prep to target Jiangdong envoy with Zhuge Liang, Sun Qian, and Zhao Yun recommendations: ${JSON.stringify(jiangdongSortieState)}`); } assertSortieTacticalRoster(jiangdongSortieState, [ 'liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian', 'zhao-yun', 'zhuge-liang' ]); const jiangdongPriorityUnits = ['zhuge-liang', 'sun-qian', 'zhao-yun']; for (const unitId of jiangdongPriorityUnits) { const currentSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!currentSortieState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected)) { const removable = currentSortieState.sortieRoster?.find( (unit) => unit.selected && unit.id !== 'liu-bei' && !jiangdongPriorityUnits.includes(unit.id) ); if (removable) { await clickSortieRosterUnit(page, removable.id); } await clickSortieRosterUnit(page, unitId); } } const jiangdongSortieReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !jiangdongPriorityUnits.every((unitId) => jiangdongSortieReadyState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected) ) || jiangdongSortieReadyState.sortiePlan?.selectedCount !== 6 || jiangdongSortieReadyState.sortiePlan?.recommendedSelectedCount < 5 ) { throw new Error(`Expected Jiangdong envoy sortie to deploy priority officers while preserving six-officer pressure: ${JSON.stringify(jiangdongSortieReadyState)}`); } await page.screenshot({ path: 'dist/verification-jiangdong-sortie.png', fullPage: true }); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-jiangdong-story.png', fullPage: true }); for (let i = 0; i < 32; i += 1) { const enteredTwentiethBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twentieth-battle-jiangdong-envoy'; }); if (enteredTwentiethBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twentieth-battle-jiangdong-envoy' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-twentieth-battle.png', fullPage: true }); const twentiethBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const twentiethEnemies = twentiethBattleState.units.filter((unit) => unit.faction === 'enemy'); const twentiethAllies = twentiethBattleState.units.filter((unit) => unit.faction === 'ally'); const twentiethEnemyBehaviors = new Set(twentiethEnemies.map((unit) => unit.ai)); if ( twentiethBattleState.camera?.mapWidth !== 40 || twentiethBattleState.camera?.mapHeight !== 28 || twentiethBattleState.victoryConditionLabel !== '문빙 퇴각' || twentiethEnemies.length < 17 || !twentiethEnemyBehaviors.has('aggressive') || !twentiethEnemyBehaviors.has('guard') || !twentiethEnemyBehaviors.has('hold') || !twentiethEnemies.some((unit) => unit.id === 'envoy-road-leader-wen-pin') || !twentiethAllies.some((unit) => unit.id === 'sun-qian') || !twentiethAllies.some((unit) => unit.id === 'zhuge-liang') ) { throw new Error(`Expected twentieth battle to use Jiangdong envoy map, Wen Pin objective, mixed AI, Sun Qian, and Zhuge Liang: ${JSON.stringify(twentiethBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const twentiethCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( twentiethCampState?.campBattleId !== 'twentieth-battle-jiangdong-envoy' || twentiethCampState.campTitle !== '강동 사절 준비 군영' || twentiethCampState.availableDialogueIds?.length !== 3 || !twentiethCampState.availableDialogueIds.every((id) => id.endsWith('jiangdong-envoy')) || twentiethCampState.availableVisitIds?.length !== 2 || !twentiethCampState.report?.bonds?.some((bond) => bond.id === 'sun-qian__zhuge-liang') || !twentiethCampState.report?.bonds?.some((bond) => bond.id === 'zhao-yun__zhuge-liang') || twentiethCampState.rosterCollection?.total < 8 || twentiethCampState.rosterCollection?.reserveTrainingCount < 2 || twentiethCampState.rosterCollection?.reserveTrainingExp < 12 ) { throw new Error(`Expected twentieth camp to expose Jiangdong dialogue/visit sets, envoy bonds, and reserve growth: ${JSON.stringify(twentiethCampState)}`); } await page.screenshot({ path: 'dist/verification-jiangdong-camp.png', fullPage: true }); await page.mouse.click(966, 38); await page.waitForTimeout(180); const postJiangdongProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( postJiangdongProgressState?.activeTab !== 'progress' || postJiangdongProgressState.campaignProgress?.completedKnown !== 20 || postJiangdongProgressState.campaignProgress?.totalKnown !== 26 || postJiangdongProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || postJiangdongProgressState.campaignProgress?.latestBattleTitle !== '강동 사절로' || postJiangdongProgressState.campaignProgress?.nextBattleTitle !== '적벽 전초전' ) { throw new Error(`Expected post-Jiangdong progress tab to keep Red Cliffs active while first Red Cliffs battle is pending: ${JSON.stringify(postJiangdongProgressState?.campaignProgress)}`); } await page.screenshot({ path: 'dist/verification-post-jiangdong-progress.png', fullPage: true }); await page.mouse.click(1120, 38); await page.waitForTimeout(180); const redCliffsSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !redCliffsSortieState?.sortieVisible || !redCliffsSortieState.sortiePlan?.objectiveLine?.includes('적벽 전초전') || !redCliffsSortieState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.recruited && unit.recommended) || !redCliffsSortieState.sortieRoster?.some((unit) => unit.id === 'zhao-yun' && unit.recruited && unit.recommended) || !redCliffsSortieState.sortieRoster?.some((unit) => unit.id === 'guan-yu' && unit.recommended) || !redCliffsSortieState.sortieRoster?.some((unit) => unit.id === 'sun-qian' && unit.recruited && unit.recommended) || redCliffsSortieState.sortieRoster?.length < 8 || redCliffsSortieState.sortiePlan?.maxCount !== 6 ) { throw new Error(`Expected twentieth camp sortie prep to target Red Cliffs vanguard with Zhuge Liang, Zhao Yun, Guan Yu, and Sun Qian recommendations: ${JSON.stringify(redCliffsSortieState)}`); } assertSortieTacticalRoster(redCliffsSortieState, [ 'liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian', 'zhao-yun', 'zhuge-liang' ]); const redCliffsPriorityUnits = ['zhuge-liang', 'zhao-yun', 'guan-yu', 'sun-qian']; for (const unitId of redCliffsPriorityUnits) { const currentSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!currentSortieState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected)) { const removable = currentSortieState.sortieRoster?.find( (unit) => unit.selected && unit.id !== 'liu-bei' && !redCliffsPriorityUnits.includes(unit.id) ); if (removable) { await clickSortieRosterUnit(page, removable.id); } await clickSortieRosterUnit(page, unitId); } } const redCliffsSortieReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !redCliffsPriorityUnits.every((unitId) => redCliffsSortieReadyState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected) ) || redCliffsSortieReadyState.sortiePlan?.selectedCount !== 6 || redCliffsSortieReadyState.sortiePlan?.recommendedSelectedCount < 5 ) { throw new Error(`Expected Red Cliffs sortie to deploy priority officers while preserving six-officer choice pressure: ${JSON.stringify(redCliffsSortieReadyState)}`); } await page.screenshot({ path: 'dist/verification-redcliffs-sortie.png', fullPage: true }); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-redcliffs-story.png', fullPage: true }); for (let i = 0; i < 34; i += 1) { const enteredTwentyFirstBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twenty-first-battle-red-cliffs-vanguard'; }); if (enteredTwentyFirstBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twenty-first-battle-red-cliffs-vanguard' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-twenty-first-battle.png', fullPage: true }); const twentyFirstBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const twentyFirstEnemies = twentyFirstBattleState.units.filter((unit) => unit.faction === 'enemy'); const twentyFirstAllies = twentyFirstBattleState.units.filter((unit) => unit.faction === 'ally'); const twentyFirstEnemyBehaviors = new Set(twentyFirstEnemies.map((unit) => unit.ai)); if ( twentyFirstBattleState.camera?.mapWidth !== 42 || twentyFirstBattleState.camera?.mapHeight !== 30 || twentyFirstBattleState.victoryConditionLabel !== '채모 퇴각' || twentyFirstEnemies.length < 17 || !twentyFirstEnemyBehaviors.has('aggressive') || !twentyFirstEnemyBehaviors.has('guard') || !twentyFirstEnemyBehaviors.has('hold') || !twentyFirstEnemies.some((unit) => unit.id === 'redcliff-vanguard-leader-cai-mao') || !twentyFirstAllies.some((unit) => unit.id === 'zhuge-liang') || !twentyFirstAllies.some((unit) => unit.id === 'zhao-yun') || !twentyFirstAllies.some((unit) => unit.id === 'sun-qian') ) { throw new Error(`Expected twenty-first battle to use Red Cliffs vanguard map, Cai Mao objective, mixed AI, and selected allied officers: ${JSON.stringify(twentyFirstBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const twentyFirstCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( twentyFirstCampState?.campBattleId !== 'twenty-first-battle-red-cliffs-vanguard' || twentyFirstCampState.campTitle !== '적벽 전초전 후 군영' || twentyFirstCampState.availableDialogueIds?.length !== 3 || !twentyFirstCampState.availableDialogueIds.every((id) => id.endsWith('red-cliffs-vanguard')) || twentyFirstCampState.availableVisitIds?.length !== 2 || !twentyFirstCampState.report?.bonds?.some((bond) => bond.id === 'liu-bei__zhuge-liang') || !twentyFirstCampState.report?.bonds?.some((bond) => bond.id === 'sun-qian__zhuge-liang') || twentyFirstCampState.rosterCollection?.total < 8 || twentyFirstCampState.rosterCollection?.reserveTrainingCount < 2 || twentyFirstCampState.rosterCollection?.reserveTrainingExp < 12 ) { throw new Error(`Expected twenty-first camp to expose Red Cliffs dialogue/visit sets, alliance bonds, and reserve growth: ${JSON.stringify(twentyFirstCampState)}`); } await page.screenshot({ path: 'dist/verification-redcliffs-camp.png', fullPage: true }); await page.mouse.click(966, 38); await page.waitForTimeout(180); const postRedCliffsProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( postRedCliffsProgressState?.activeTab !== 'progress' || postRedCliffsProgressState.campaignProgress?.completedKnown !== 21 || postRedCliffsProgressState.campaignProgress?.totalKnown !== 26 || postRedCliffsProgressState.campaignProgress?.activeChapter?.title !== '적벽대전' || postRedCliffsProgressState.campaignProgress?.latestBattleTitle !== '적벽 전초전' || postRedCliffsProgressState.campaignProgress?.nextBattleTitle !== '적벽 화공전' ) { throw new Error(`Expected post-Red Cliffs vanguard progress tab to complete known battles and keep fire attack preparation pending: ${JSON.stringify(postRedCliffsProgressState?.campaignProgress)}`); } await page.screenshot({ path: 'dist/verification-post-redcliffs-progress.png', fullPage: true }); await page.mouse.click(1120, 38); await page.waitForTimeout(180); const fireAttackSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !fireAttackSortieState?.sortieVisible || !fireAttackSortieState.sortiePlan?.objectiveLine?.includes('적벽 화공전') || !fireAttackSortieState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.recruited && unit.recommended) || !fireAttackSortieState.sortieRoster?.some((unit) => unit.id === 'guan-yu' && unit.recommended) || !fireAttackSortieState.sortieRoster?.some((unit) => unit.id === 'zhao-yun' && unit.recruited && unit.recommended) || !fireAttackSortieState.sortieRoster?.some((unit) => unit.id === 'mi-zhu' && unit.recruited && unit.recommended) || fireAttackSortieState.sortieRoster?.length < 8 || fireAttackSortieState.sortiePlan?.maxCount !== 6 ) { throw new Error(`Expected twenty-first camp sortie prep to target Red Cliffs fire attack with Zhuge Liang, Guan Yu, Zhao Yun, and Mi Zhu recommendations: ${JSON.stringify(fireAttackSortieState)}`); } assertSortieTacticalRoster(fireAttackSortieState, [ 'liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian', 'zhao-yun', 'zhuge-liang' ]); const fireAttackPriorityUnits = ['zhuge-liang', 'guan-yu', 'zhao-yun', 'mi-zhu']; for (const unitId of fireAttackPriorityUnits) { const currentSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!currentSortieState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected)) { const removable = currentSortieState.sortieRoster?.find( (unit) => unit.selected && unit.id !== 'liu-bei' && !fireAttackPriorityUnits.includes(unit.id) ); if (removable) { await clickSortieRosterUnit(page, removable.id); } await clickSortieRosterUnit(page, unitId); } } const fireAttackSortieReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !fireAttackPriorityUnits.every((unitId) => fireAttackSortieReadyState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected) ) || fireAttackSortieReadyState.sortiePlan?.selectedCount !== 6 || fireAttackSortieReadyState.sortiePlan?.recommendedSelectedCount < 5 ) { throw new Error(`Expected Red Cliffs fire attack sortie to deploy priority officers while preserving six-officer choice pressure: ${JSON.stringify(fireAttackSortieReadyState)}`); } await page.screenshot({ path: 'dist/verification-redcliffs-fire-sortie.png', fullPage: true }); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-redcliffs-fire-story.png', fullPage: true }); for (let i = 0; i < 36; i += 1) { const enteredTwentySecondBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twenty-second-battle-red-cliffs-fire'; }); if (enteredTwentySecondBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twenty-second-battle-red-cliffs-fire' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-twenty-second-battle.png', fullPage: true }); const twentySecondBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const twentySecondEnemies = twentySecondBattleState.units.filter((unit) => unit.faction === 'enemy'); const twentySecondAllies = twentySecondBattleState.units.filter((unit) => unit.faction === 'ally'); const twentySecondEnemyBehaviors = new Set(twentySecondEnemies.map((unit) => unit.ai)); if ( twentySecondBattleState.camera?.mapWidth !== 44 || twentySecondBattleState.camera?.mapHeight !== 30 || twentySecondBattleState.victoryConditionLabel !== '조조 본선 퇴각' || twentySecondEnemies.length < 18 || !twentySecondEnemyBehaviors.has('aggressive') || !twentySecondEnemyBehaviors.has('guard') || !twentySecondEnemyBehaviors.has('hold') || !twentySecondEnemies.some((unit) => unit.id === 'redcliff-fire-leader-cao-cao') || !twentySecondAllies.some((unit) => unit.id === 'zhuge-liang') || !twentySecondAllies.some((unit) => unit.id === 'zhao-yun') || !twentySecondAllies.some((unit) => unit.id === 'mi-zhu') ) { throw new Error(`Expected twenty-second battle to use Red Cliffs fire map, Cao Cao objective, mixed AI, and selected allied officers: ${JSON.stringify(twentySecondBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const twentySecondCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( twentySecondCampState?.campBattleId !== 'twenty-second-battle-red-cliffs-fire' || twentySecondCampState.campTitle !== '적벽 화공전 후 군영' || twentySecondCampState.availableDialogueIds?.length !== 3 || !twentySecondCampState.availableDialogueIds.every((id) => id.endsWith('red-cliffs-fire')) || twentySecondCampState.availableVisitIds?.length !== 2 || !twentySecondCampState.report?.bonds?.some((bond) => bond.id === 'liu-bei__zhuge-liang') || !twentySecondCampState.report?.bonds?.some((bond) => bond.id === 'liu-bei__mi-zhu') || twentySecondCampState.rosterCollection?.total < 8 || twentySecondCampState.rosterCollection?.reserveTrainingCount < 2 || twentySecondCampState.rosterCollection?.reserveTrainingExp < 12 ) { throw new Error(`Expected twenty-second camp to expose Red Cliffs fire dialogue/visit sets, aftermath bonds, and reserve growth: ${JSON.stringify(twentySecondCampState)}`); } await page.screenshot({ path: 'dist/verification-redcliffs-fire-camp.png', fullPage: true }); await page.mouse.click(966, 38); await page.waitForTimeout(180); const postFireAttackProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( postFireAttackProgressState?.activeTab !== 'progress' || postFireAttackProgressState.campaignProgress?.completedKnown !== 22 || postFireAttackProgressState.campaignProgress?.totalKnown !== 26 || postFireAttackProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postFireAttackProgressState.campaignProgress?.latestBattleTitle !== '적벽 화공전' || postFireAttackProgressState.campaignProgress?.nextBattleTitle !== '형주 남부 진입전' ) { throw new Error(`Expected post-Red Cliffs fire progress tab to complete known battles and point toward Jing Province preparation: ${JSON.stringify(postFireAttackProgressState?.campaignProgress)}`); } await page.screenshot({ path: 'dist/verification-post-redcliffs-fire-progress.png', fullPage: true }); await page.mouse.click(1120, 38); await page.waitForTimeout(180); const jingzhouSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !jingzhouSortieState?.sortieVisible || !jingzhouSortieState.sortiePlan?.objectiveLine?.includes('형주 남부 진입전') || !jingzhouSortieState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.recruited && unit.recommended) || !jingzhouSortieState.sortieRoster?.some((unit) => unit.id === 'guan-yu' && unit.recommended) || !jingzhouSortieState.sortieRoster?.some((unit) => unit.id === 'zhao-yun' && unit.recruited && unit.recommended) || !jingzhouSortieState.sortieRoster?.some((unit) => unit.id === 'sun-qian' && unit.recruited && unit.recommended) || !jingzhouSortieState.sortieRoster?.some((unit) => unit.id === 'mi-zhu' && unit.recruited && unit.recommended) || jingzhouSortieState.sortieRoster?.some((unit) => unit.id === 'ma-liang') || jingzhouSortieState.sortieRoster?.length < 8 || jingzhouSortieState.sortiePlan?.maxCount !== 6 ) { throw new Error(`Expected twenty-second camp sortie prep to target Jing Province entry with existing recruited officers and no Ma Liang before victory: ${JSON.stringify(jingzhouSortieState)}`); } assertSortieTacticalRoster(jingzhouSortieState, [ 'liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian', 'zhao-yun', 'zhuge-liang' ]); const jingzhouPriorityUnits = ['zhuge-liang', 'guan-yu', 'zhao-yun', 'sun-qian', 'mi-zhu']; for (const unitId of jingzhouPriorityUnits) { const currentSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!currentSortieState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected)) { const removable = currentSortieState.sortieRoster?.find( (unit) => unit.selected && unit.id !== 'liu-bei' && !jingzhouPriorityUnits.includes(unit.id) ); if (removable) { await clickSortieRosterUnit(page, removable.id); } await clickSortieRosterUnit(page, unitId); } } const jingzhouSortieReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !jingzhouPriorityUnits.every((unitId) => jingzhouSortieReadyState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected) ) || jingzhouSortieReadyState.sortiePlan?.selectedCount !== 6 || jingzhouSortieReadyState.sortiePlan?.recommendedSelectedCount < 6 ) { throw new Error(`Expected Jing Province entry sortie to deploy all recommended priority officers while preserving six-officer pressure: ${JSON.stringify(jingzhouSortieReadyState)}`); } await page.screenshot({ path: 'dist/verification-jingzhou-sortie.png', fullPage: true }); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-jingzhou-story.png', fullPage: true }); for (let i = 0; i < 40; i += 1) { const enteredTwentyThirdBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twenty-third-battle-jingzhou-south-entry'; }); if (enteredTwentyThirdBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twenty-third-battle-jingzhou-south-entry' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-twenty-third-battle.png', fullPage: true }); const twentyThirdBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const twentyThirdEnemies = twentyThirdBattleState.units.filter((unit) => unit.faction === 'enemy'); const twentyThirdAllies = twentyThirdBattleState.units.filter((unit) => unit.faction === 'ally'); const twentyThirdEnemyBehaviors = new Set(twentyThirdEnemies.map((unit) => unit.ai)); if ( twentyThirdBattleState.camera?.mapWidth !== 46 || twentyThirdBattleState.camera?.mapHeight !== 32 || twentyThirdBattleState.victoryConditionLabel !== '형도영 퇴각' || twentyThirdEnemies.length < 17 || !twentyThirdEnemyBehaviors.has('aggressive') || !twentyThirdEnemyBehaviors.has('guard') || !twentyThirdEnemyBehaviors.has('hold') || !twentyThirdEnemies.some((unit) => unit.id === 'jing-south-leader-xing-daorong') || !twentyThirdAllies.some((unit) => unit.id === 'zhuge-liang') || !twentyThirdAllies.some((unit) => unit.id === 'zhao-yun') || !twentyThirdAllies.some((unit) => unit.id === 'sun-qian') || twentyThirdAllies.some((unit) => unit.id === 'ma-liang') ) { throw new Error(`Expected twenty-third battle to use Jing Province south map, Xing Daorong objective, mixed AI, selected allied officers, and no Ma Liang before victory: ${JSON.stringify(twentyThirdBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const twentyThirdCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( twentyThirdCampState?.campBattleId !== 'twenty-third-battle-jingzhou-south-entry' || twentyThirdCampState.campTitle !== '형주 남부 진입 후 군영' || twentyThirdCampState.availableDialogueIds?.length !== 3 || !twentyThirdCampState.availableDialogueIds.every((id) => id.endsWith('jingzhou-south-entry')) || twentyThirdCampState.availableVisitIds?.length !== 2 || !twentyThirdCampState.campaign?.roster?.some((unit) => unit.id === 'ma-liang') || !twentyThirdCampState.sortieRoster?.some((unit) => unit.id === 'ma-liang' && unit.recruited) || twentyThirdCampState.rosterCollection?.total < 9 || twentyThirdCampState.rosterCollection?.reserveTrainingCount < 2 || twentyThirdCampState.rosterCollection?.reserveTrainingExp < 12 ) { throw new Error(`Expected twenty-third camp to recruit Ma Liang and expose Jing Province dialogue/visit sets: ${JSON.stringify(twentyThirdCampState)}`); } await page.screenshot({ path: 'dist/verification-jingzhou-camp.png', fullPage: true }); await page.mouse.click(966, 38); await page.waitForTimeout(180); const postJingzhouProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( postJingzhouProgressState?.activeTab !== 'progress' || postJingzhouProgressState.campaignProgress?.completedKnown !== 23 || postJingzhouProgressState.campaignProgress?.totalKnown !== 26 || postJingzhouProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postJingzhouProgressState.campaignProgress?.latestBattleTitle !== '형주 남부 진입전' || postJingzhouProgressState.campaignProgress?.nextBattleTitle !== '계양 설득전' ) { throw new Error(`Expected post-Jing Province progress tab to complete the new chapter opening and pause on next commandery preparation: ${JSON.stringify(postJingzhouProgressState?.campaignProgress)}`); } await page.screenshot({ path: 'dist/verification-post-jingzhou-progress.png', fullPage: true }); await page.mouse.click(1120, 38); await page.waitForTimeout(180); const guiyangSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !guiyangSortieState?.sortieVisible || !guiyangSortieState.sortiePlan?.objectiveLine?.includes('계양 설득전') || !guiyangSortieState.sortieRoster?.some((unit) => unit.id === 'ma-liang' && unit.recruited && unit.recommended) || !guiyangSortieState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.recruited && unit.recommended) || !guiyangSortieState.sortieRoster?.some((unit) => unit.id === 'zhao-yun' && unit.recruited && unit.recommended) || !guiyangSortieState.sortieRoster?.some((unit) => unit.id === 'guan-yu' && unit.recommended) || !guiyangSortieState.sortieRoster?.some((unit) => unit.id === 'sun-qian' && unit.recruited && unit.recommended) || guiyangSortieState.sortieRoster?.some((unit) => unit.id === 'yi-ji') || guiyangSortieState.sortieRoster?.length < 9 || guiyangSortieState.sortiePlan?.maxCount !== 6 ) { throw new Error(`Expected twenty-third camp sortie prep to target Guiyang with Ma Liang recommended and Yi Ji absent before victory: ${JSON.stringify(guiyangSortieState)}`); } assertSortieTacticalRoster(guiyangSortieState, [ 'liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian', 'zhao-yun', 'zhuge-liang', 'ma-liang' ]); const guiyangPriorityUnits = ['ma-liang', 'zhuge-liang', 'zhao-yun', 'guan-yu', 'sun-qian']; for (const unitId of guiyangPriorityUnits) { const currentSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!currentSortieState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected)) { const removable = currentSortieState.sortieRoster?.find( (unit) => unit.selected && unit.id !== 'liu-bei' && !guiyangPriorityUnits.includes(unit.id) ); if (removable) { await clickSortieRosterUnit(page, removable.id); } await clickSortieRosterUnit(page, unitId); } } const guiyangSortieReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !guiyangPriorityUnits.every((unitId) => guiyangSortieReadyState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected) ) || guiyangSortieReadyState.sortiePlan?.selectedCount !== 6 || guiyangSortieReadyState.sortiePlan?.recommendedSelectedCount < 6 ) { throw new Error(`Expected Guiyang sortie to deploy Ma Liang and other recommended officers while preserving six-officer pressure: ${JSON.stringify(guiyangSortieReadyState)}`); } await page.screenshot({ path: 'dist/verification-guiyang-sortie.png', fullPage: true }); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-guiyang-story.png', fullPage: true }); for (let i = 0; i < 50; i += 1) { const enteredTwentyFourthBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twenty-fourth-battle-guiyang-persuasion'; }); if (enteredTwentyFourthBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twenty-fourth-battle-guiyang-persuasion' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-twenty-fourth-battle.png', fullPage: true }); const twentyFourthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const twentyFourthEnemies = twentyFourthBattleState.units.filter((unit) => unit.faction === 'enemy'); const twentyFourthAllies = twentyFourthBattleState.units.filter((unit) => unit.faction === 'ally'); const twentyFourthEnemyBehaviors = new Set(twentyFourthEnemies.map((unit) => unit.ai)); if ( twentyFourthBattleState.camera?.mapWidth !== 48 || twentyFourthBattleState.camera?.mapHeight !== 32 || twentyFourthBattleState.victoryConditionLabel !== '조범 항복' || twentyFourthEnemies.length < 17 || !twentyFourthEnemyBehaviors.has('aggressive') || !twentyFourthEnemyBehaviors.has('guard') || !twentyFourthEnemyBehaviors.has('hold') || !twentyFourthEnemies.some((unit) => unit.id === 'guiyang-leader-zhao-fan') || !twentyFourthAllies.some((unit) => unit.id === 'ma-liang') || !twentyFourthAllies.some((unit) => unit.id === 'zhuge-liang') || !twentyFourthAllies.some((unit) => unit.id === 'sun-qian') || twentyFourthAllies.some((unit) => unit.id === 'yi-ji') ) { throw new Error(`Expected twenty-fourth battle to use Guiyang map, Zhao Fan objective, mixed AI, selected allied officers, and no Yi Ji before victory: ${JSON.stringify(twentyFourthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const twentyFourthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( twentyFourthCampState?.campBattleId !== 'twenty-fourth-battle-guiyang-persuasion' || twentyFourthCampState.campTitle !== '계양 설득전 후 군영' || twentyFourthCampState.availableDialogueIds?.length !== 3 || !twentyFourthCampState.availableDialogueIds.every((id) => id.endsWith('guiyang')) || twentyFourthCampState.availableVisitIds?.length !== 2 || !twentyFourthCampState.campaign?.roster?.some((unit) => unit.id === 'yi-ji') || !twentyFourthCampState.sortieRoster?.some((unit) => unit.id === 'yi-ji' && unit.recruited) || twentyFourthCampState.rosterCollection?.total < 10 ) { throw new Error(`Expected twenty-fourth camp to recruit Yi Ji and expose Guiyang dialogue/visit sets: ${JSON.stringify(twentyFourthCampState)}`); } await page.screenshot({ path: 'dist/verification-guiyang-camp.png', fullPage: true }); await page.mouse.click(966, 38); await page.waitForTimeout(180); const postGuiyangProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( postGuiyangProgressState?.activeTab !== 'progress' || postGuiyangProgressState.campaignProgress?.completedKnown !== 24 || postGuiyangProgressState.campaignProgress?.totalKnown !== 26 || postGuiyangProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postGuiyangProgressState.campaignProgress?.latestBattleTitle !== '계양 설득전' || postGuiyangProgressState.campaignProgress?.nextBattleTitle !== '무릉 산길 확보전' ) { throw new Error(`Expected post-Guiyang progress tab to complete the new commandery battle and pause on Wuling preparation: ${JSON.stringify(postGuiyangProgressState?.campaignProgress)}`); } await page.screenshot({ path: 'dist/verification-post-guiyang-progress.png', fullPage: true }); await page.mouse.click(1120, 38); await page.waitForTimeout(180); const wulingSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !wulingSortieState?.sortieVisible || !wulingSortieState.sortiePlan?.objectiveLine?.includes('무릉 산길 확보전') || !wulingSortieState.sortieRoster?.some((unit) => unit.id === 'ma-liang' && unit.recruited && unit.recommended) || !wulingSortieState.sortieRoster?.some((unit) => unit.id === 'yi-ji' && unit.recruited && unit.recommended) || !wulingSortieState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.recruited && unit.recommended) || !wulingSortieState.sortieRoster?.some((unit) => unit.id === 'zhao-yun' && unit.recruited && unit.recommended) || !wulingSortieState.sortieRoster?.some((unit) => unit.id === 'zhang-fei' && unit.recommended) || wulingSortieState.sortieRoster?.some((unit) => unit.id === 'gong-zhi') || wulingSortieState.sortieRoster?.length < 10 || wulingSortieState.sortiePlan?.maxCount !== 6 ) { throw new Error(`Expected twenty-fourth camp sortie prep to target Wuling with Yi Ji recommended and Gong Zhi absent before victory: ${JSON.stringify(wulingSortieState)}`); } assertSortieTacticalRoster(wulingSortieState, [ 'liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian', 'zhao-yun', 'zhuge-liang', 'ma-liang', 'yi-ji' ]); const wulingPriorityUnits = ['ma-liang', 'yi-ji', 'zhuge-liang', 'zhao-yun', 'zhang-fei']; for (const unitId of wulingPriorityUnits) { const currentSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!currentSortieState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected)) { const removable = currentSortieState.sortieRoster?.find( (unit) => unit.selected && unit.id !== 'liu-bei' && !wulingPriorityUnits.includes(unit.id) ); if (removable) { await clickSortieRosterUnit(page, removable.id); } await clickSortieRosterUnit(page, unitId); } } const wulingSortieReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !wulingPriorityUnits.every((unitId) => wulingSortieReadyState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected) ) || wulingSortieReadyState.sortiePlan?.selectedCount !== 6 || wulingSortieReadyState.sortiePlan?.recommendedSelectedCount < 6 ) { throw new Error(`Expected Wuling sortie to deploy Ma Liang, Yi Ji, and the mountain-road priority officers while preserving six-officer pressure: ${JSON.stringify(wulingSortieReadyState)}`); } await page.screenshot({ path: 'dist/verification-wuling-sortie.png', fullPage: true }); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-wuling-story.png', fullPage: true }); for (let i = 0; i < 50; i += 1) { const enteredTwentyFifthBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twenty-fifth-battle-wuling-mountain-road'; }); if (enteredTwentyFifthBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twenty-fifth-battle-wuling-mountain-road' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-twenty-fifth-battle.png', fullPage: true }); const twentyFifthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const twentyFifthEnemies = twentyFifthBattleState.units.filter((unit) => unit.faction === 'enemy'); const twentyFifthAllies = twentyFifthBattleState.units.filter((unit) => unit.faction === 'ally'); const twentyFifthEnemyBehaviors = new Set(twentyFifthEnemies.map((unit) => unit.ai)); if ( twentyFifthBattleState.camera?.mapWidth !== 50 || twentyFifthBattleState.camera?.mapHeight !== 34 || twentyFifthBattleState.victoryConditionLabel !== '김선 퇴각' || twentyFifthEnemies.length < 19 || !twentyFifthEnemyBehaviors.has('aggressive') || !twentyFifthEnemyBehaviors.has('guard') || !twentyFifthEnemyBehaviors.has('hold') || !twentyFifthEnemies.some((unit) => unit.id === 'wuling-leader-jin-xuan') || !twentyFifthAllies.some((unit) => unit.id === 'ma-liang') || !twentyFifthAllies.some((unit) => unit.id === 'yi-ji') || !twentyFifthAllies.some((unit) => unit.id === 'zhao-yun') || twentyFifthAllies.some((unit) => unit.id === 'gong-zhi') ) { throw new Error(`Expected twenty-fifth battle to use Wuling map, Jin Xuan objective, mixed AI, selected allied officers, and no Gong Zhi before victory: ${JSON.stringify(twentyFifthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const twentyFifthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( twentyFifthCampState?.campBattleId !== 'twenty-fifth-battle-wuling-mountain-road' || twentyFifthCampState.campTitle !== '무릉 산길 확보 후 군영' || twentyFifthCampState.availableDialogueIds?.length !== 3 || !twentyFifthCampState.availableDialogueIds.every((id) => id.endsWith('wuling')) || twentyFifthCampState.availableVisitIds?.length !== 2 || !twentyFifthCampState.campaign?.roster?.some((unit) => unit.id === 'gong-zhi') || !twentyFifthCampState.sortieRoster?.some((unit) => unit.id === 'gong-zhi' && unit.recruited) || twentyFifthCampState.rosterCollection?.total < 11 ) { throw new Error(`Expected twenty-fifth camp to recruit Gong Zhi and expose Wuling dialogue/visit sets: ${JSON.stringify(twentyFifthCampState)}`); } await page.screenshot({ path: 'dist/verification-wuling-camp.png', fullPage: true }); await page.mouse.click(966, 38); await page.waitForTimeout(180); const postWulingProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( postWulingProgressState?.activeTab !== 'progress' || postWulingProgressState.campaignProgress?.completedKnown !== 25 || postWulingProgressState.campaignProgress?.totalKnown !== 26 || postWulingProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postWulingProgressState.campaignProgress?.latestBattleTitle !== '무릉 산길 확보전' || postWulingProgressState.campaignProgress?.nextBattleTitle !== '장사 노장 대면전' ) { throw new Error(`Expected post-Wuling progress tab to complete the mountain-road battle and pause on Changsha preparation: ${JSON.stringify(postWulingProgressState?.campaignProgress)}`); } await page.screenshot({ path: 'dist/verification-post-wuling-progress.png', fullPage: true }); await page.mouse.click(1120, 38); await page.waitForTimeout(180); const changshaSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !changshaSortieState?.sortieVisible || !changshaSortieState.sortiePlan?.objectiveLine?.includes('장사 노장 대면전') || !changshaSortieState.sortieRoster?.some((unit) => unit.id === 'gong-zhi' && unit.recruited && unit.recommended) || !changshaSortieState.sortieRoster?.some((unit) => unit.id === 'yi-ji' && unit.recruited && unit.recommended) || !changshaSortieState.sortieRoster?.some((unit) => unit.id === 'zhuge-liang' && unit.recruited && unit.recommended) || !changshaSortieState.sortieRoster?.some((unit) => unit.id === 'guan-yu' && unit.recommended) || !changshaSortieState.sortieRoster?.some((unit) => unit.id === 'zhao-yun' && unit.recruited && unit.recommended) || changshaSortieState.sortieRoster?.some((unit) => unit.id === 'huang-zhong') || changshaSortieState.sortieRoster?.some((unit) => unit.id === 'wei-yan') || changshaSortieState.sortieRoster?.length < 11 || changshaSortieState.sortiePlan?.maxCount !== 6 ) { throw new Error(`Expected twenty-fifth camp sortie prep to target Changsha with Gong Zhi recommended and Huang Zhong/Wei Yan absent before victory: ${JSON.stringify(changshaSortieState)}`); } assertSortieTacticalRoster(changshaSortieState, [ 'liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian', 'zhao-yun', 'zhuge-liang', 'ma-liang', 'yi-ji', 'gong-zhi' ]); const changshaPriorityUnits = ['gong-zhi', 'yi-ji', 'zhuge-liang', 'guan-yu', 'zhao-yun']; for (const unitId of changshaPriorityUnits) { const currentSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!currentSortieState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected)) { const removable = currentSortieState.sortieRoster?.find( (unit) => unit.selected && unit.id !== 'liu-bei' && !changshaPriorityUnits.includes(unit.id) ); if (removable) { await clickSortieRosterUnit(page, removable.id); } await clickSortieRosterUnit(page, unitId); } } const changshaSortieReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !changshaPriorityUnits.every((unitId) => changshaSortieReadyState.sortieRoster?.some((unit) => unit.id === unitId && unit.selected) ) || changshaSortieReadyState.sortiePlan?.selectedCount !== 6 || changshaSortieReadyState.sortiePlan?.recommendedSelectedCount < 6 ) { throw new Error(`Expected Changsha sortie to deploy Gong Zhi and the veteran-audience priority officers while preserving six-officer pressure: ${JSON.stringify(changshaSortieReadyState)}`); } await page.screenshot({ path: 'dist/verification-changsha-sortie.png', fullPage: true }); await page.mouse.click(1068, 646); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); await page.screenshot({ path: 'dist/verification-changsha-story.png', fullPage: true }); for (let i = 0; i < 50; i += 1) { const enteredTwentySixthBattle = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twenty-sixth-battle-changsha-veteran'; }); if (enteredTwentySixthBattle) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'twenty-sixth-battle-changsha-veteran' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-twenty-sixth-battle.png', fullPage: true }); const twentySixthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const twentySixthEnemies = twentySixthBattleState.units.filter((unit) => unit.faction === 'enemy'); const twentySixthAllies = twentySixthBattleState.units.filter((unit) => unit.faction === 'ally'); const twentySixthEnemyBehaviors = new Set(twentySixthEnemies.map((unit) => unit.ai)); if ( twentySixthBattleState.camera?.mapWidth !== 52 || twentySixthBattleState.camera?.mapHeight !== 34 || twentySixthBattleState.victoryConditionLabel !== '한현 퇴각' || twentySixthEnemies.length < 18 || !twentySixthEnemyBehaviors.has('aggressive') || !twentySixthEnemyBehaviors.has('guard') || !twentySixthEnemyBehaviors.has('hold') || !twentySixthEnemies.some((unit) => unit.id === 'changsha-leader-han-xuan') || !twentySixthEnemies.some((unit) => unit.id === 'changsha-veteran-huang-zhong') || !twentySixthEnemies.some((unit) => unit.id === 'changsha-officer-wei-yan') || !twentySixthAllies.some((unit) => unit.id === 'gong-zhi') || !twentySixthAllies.some((unit) => unit.id === 'yi-ji') || twentySixthAllies.some((unit) => unit.id === 'huang-zhong') || twentySixthAllies.some((unit) => unit.id === 'wei-yan') ) { throw new Error(`Expected twenty-sixth battle to use Changsha map, Han Xuan objective, Huang Zhong/Wei Yan as enemy officers, mixed AI, and no Changsha recruits before victory: ${JSON.stringify(twentySixthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const twentySixthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( twentySixthCampState?.campBattleId !== 'twenty-sixth-battle-changsha-veteran' || twentySixthCampState.campTitle !== '장사 노장 대면 후 군영' || twentySixthCampState.availableDialogueIds?.length !== 4 || !twentySixthCampState.availableDialogueIds.every((id) => id.endsWith('changsha')) || twentySixthCampState.availableVisitIds?.length !== 2 || !twentySixthCampState.campaign?.roster?.some((unit) => unit.id === 'huang-zhong') || !twentySixthCampState.campaign?.roster?.some((unit) => unit.id === 'wei-yan') || !twentySixthCampState.sortieRoster?.some((unit) => unit.id === 'huang-zhong' && unit.recruited) || !twentySixthCampState.sortieRoster?.some((unit) => unit.id === 'wei-yan' && unit.recruited) || twentySixthCampState.rosterCollection?.total < 13 ) { throw new Error(`Expected twenty-sixth camp to recruit Huang Zhong and Wei Yan and expose Changsha dialogue/visit sets: ${JSON.stringify(twentySixthCampState)}`); } await page.screenshot({ path: 'dist/verification-changsha-camp.png', fullPage: true }); await page.mouse.click(966, 38); await page.waitForTimeout(180); const postChangshaProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( postChangshaProgressState?.activeTab !== 'progress' || postChangshaProgressState.campaignProgress?.completedKnown !== 26 || postChangshaProgressState.campaignProgress?.totalKnown !== 26 || postChangshaProgressState.campaignProgress?.activeChapter?.title !== '형주·익주 확보' || postChangshaProgressState.campaignProgress?.latestBattleTitle !== '장사 노장 대면전' || postChangshaProgressState.campaignProgress?.nextBattleTitle !== '준비 중' ) { throw new Error(`Expected post-Changsha progress tab to complete the veteran-audience battle and pause on Yi Province preparation: ${JSON.stringify(postChangshaProgressState?.campaignProgress)}`); } await page.screenshot({ path: 'dist/verification-post-changsha-progress.png', fullPage: true }); await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('TitleScene'); }); await page.mouse.click(962, 310); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); console.log(`Verified title-to-twenty-sixth-battle flow, recruited officer sortie selection, Ma Liang, Yi Ji, Gong Zhi, Huang Zhong, and Wei Yan joins, result states, and debug API at ${targetUrl}`); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { serverProcess.kill(); } } async function ensureLocalServer(url) { if (await canReach(url)) { return undefined; } const parsed = new URL(url); const isLocal = ['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname); if (!isLocal) { 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 || '5173'], { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); child.stderr.on('data', (chunk) => stderr.push(chunk.toString())); child.stdout.on('data', () => {}); for (let i = 0; i < 80; i += 1) { if (await canReach(url)) { return child; } await delay(250); } child.kill(); 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)); } function assertSortieTacticalRoster(state, requiredUnitIds) { if (!state?.sortieVisible || !Array.isArray(state.sortieRoster)) { throw new Error(`Expected sortie tactical roster to be exposed: ${JSON.stringify(state)}`); } const missingIds = requiredUnitIds.filter((unitId) => !state.sortieRoster.some((unit) => unit.id === unitId)); if (missingIds.length > 0) { throw new Error(`Expected sortie roster to include ${missingIds.join(', ')}: ${JSON.stringify(state.sortieRoster)}`); } const invalidUnits = state.sortieRoster.filter((unit) => { return ( !unit.classRole || !unit.formationRole || !unit.statLine?.includes('무 ') || !unit.statLine?.includes('지 ') || !unit.equipmentLine?.includes('Lv') || !unit.bondLine?.includes('공명') || !unit.terrainLine?.includes('지형') ); }); if (invalidUnits.length > 0) { throw new Error(`Expected sortie roster to include class, stats, equipment, bond, and terrain advice: ${JSON.stringify(invalidUnits)}`); } const liuBei = state.sortieRoster.find((unit) => unit.id === 'liu-bei'); if (!liuBei?.equipmentLine?.includes('자웅일대검')) { throw new Error(`Expected Liu Bei sortie row to expose named treasure weapon: ${JSON.stringify(liuBei)}`); } if ( !state.sortiePlan || !state.sortiePlan.deploymentLine?.includes('전열') || !state.sortiePlan.objectiveLine || !state.sortiePlan.recommendationLine?.includes('추천') || !Array.isArray(state.sortiePlan.formationSlots) || state.sortiePlan.formationSlots.length < 3 || typeof state.sortiePlan.activeBondCount !== 'number' || typeof state.sortiePlan.terrainScore !== 'number' || typeof state.sortiePlan.recommendedSelectedCount !== 'number' || typeof state.sortiePlan.recommendedTotal !== 'number' ) { throw new Error(`Expected sortie preparation to expose deployment plan, bond count, and terrain score: ${JSON.stringify(state.sortiePlan)}`); } const recommendedUnits = state.sortieRoster.filter((unit) => unit.recommended); if (recommendedUnits.length === 0 || recommendedUnits.some((unit) => !unit.recommendationReason)) { throw new Error(`Expected sortie roster to expose recommended officers with reasons: ${JSON.stringify(state.sortieRoster)}`); } } async function clickSortieRosterUnit(page, unitId) { const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!state?.sortieVisible || !Array.isArray(state.sortieRoster)) { throw new Error(`Cannot click sortie unit ${unitId}; sortie roster is not visible: ${JSON.stringify(state)}`); } const index = state.sortieRoster.findIndex((unit) => unit.id === unitId); if (index < 0) { throw new Error(`Cannot click sortie unit ${unitId}; unit is missing from roster: ${JSON.stringify(state.sortieRoster)}`); } const rowGap = state.sortieRoster.length > 7 ? 30 : state.sortieRoster.length > 5 ? 38 : state.sortieRoster.length > 4 ? 43 : 48; await page.mouse.click(254, 314 + 50 + index * rowGap); await page.waitForTimeout(120); }