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(918, 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(830, 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 page.mouse.click(254, 450);
  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 page.mouse.click(256, 492);
  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 page.mouse.click(256, 516);
    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 page.mouse.click(256, 420);
    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.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-tenth-battle flow, recruit sortie selection, 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.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)}`);
  }
}
