import { spawn } from 'node:child_process'; import { chromium } from 'playwright'; const targetUrl = process.env.QA_URL ?? 'http://localhost:5173/'; const headless = process.env.QA_HEADLESS !== '0'; const maxRounds = Number(process.env.QA_MAX_ROUNDS ?? 80); const pnpmCommand = process.env.PNPM_CMD ?? 'pnpm'; const coreBrothers = ['liu-bei', 'guan-yu', 'zhang-fei']; const xuzhouFullSortie = ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu']; const caoRefugeSortie = ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu']; const sunQianSortie = ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu', 'sun-qian']; const liuBiaoSortie = ['liu-bei', 'guan-yu', 'zhang-fei', 'zhao-yun', 'sun-qian', 'mi-zhu']; const zhugeFirstSortie = ['liu-bei', 'zhuge-liang', 'zhao-yun', 'guan-yu', 'zhang-fei', 'sun-qian']; const envoySortie = ['liu-bei', 'zhuge-liang', 'sun-qian', 'zhao-yun', 'guan-yu', 'zhang-fei']; const redCliffVanguardSortie = ['liu-bei', 'zhuge-liang', 'zhao-yun', 'guan-yu', 'zhang-fei', 'sun-qian']; const redCliffFireSortie = ['liu-bei', 'zhuge-liang', 'guan-yu', 'zhao-yun', 'zhang-fei', 'mi-zhu']; const jingSouthSortie = ['liu-bei', 'zhuge-liang', 'guan-yu', 'zhao-yun', 'sun-qian', 'mi-zhu']; const guiyangSortie = ['liu-bei', 'ma-liang', 'zhuge-liang', 'zhao-yun', 'guan-yu', 'sun-qian']; const wulingSortie = ['liu-bei', 'ma-liang', 'zhuge-liang', 'zhao-yun', 'zhang-fei', 'mi-zhu']; const changshaSortie = ['liu-bei', 'gong-zhi', 'yi-ji', 'zhuge-liang', 'guan-yu', 'zhao-yun']; const yizhouReliefSortie = ['liu-bei', 'zhuge-liang', 'huang-zhong', 'wei-yan', 'zhao-yun', 'mi-zhu']; const fuPassSortie = ['liu-bei', 'pang-tong', 'zhuge-liang', 'wei-yan', 'huang-zhong', 'zhao-yun']; const luoOuterSortie = ['liu-bei', 'fa-zheng', 'zhuge-liang', 'huang-zhong', 'zhao-yun', 'mi-zhu']; const luofengSortie = ['liu-bei', 'pang-tong', 'wu-yi', 'fa-zheng', 'zhao-yun', 'huang-zhong']; const luoMainSortie = ['liu-bei', 'pang-tong', 'fa-zheng', 'wu-yi', 'huang-zhong', 'zhang-fei']; const mianzhuSortie = ['liu-bei', 'yan-yan', 'fa-zheng', 'wu-yi', 'pang-tong', 'huang-zhong']; const chengduSortie = ['liu-bei', 'li-yan', 'zhuge-liang', 'fa-zheng', 'yan-yan', 'wu-yi']; const jiamengSortie = ['liu-bei', 'huang-quan', 'zhao-yun', 'zhang-fei', 'fa-zheng', 'huang-zhong']; const yangpingSortie = ['liu-bei', 'ma-chao', 'huang-quan', 'zhuge-liang', 'zhao-yun', 'fa-zheng']; const dingjunSortie = ['liu-bei', 'huang-zhong', 'fa-zheng', 'ma-chao', 'ma-dai', 'mi-zhu']; const hanzhongSortie = ['liu-bei', 'huang-zhong', 'fa-zheng', 'wang-ping', 'ma-chao', 'ma-dai', 'zhuge-liang']; const jingDefenseSortie = ['liu-bei', 'guan-yu', 'zhang-fei', 'ma-liang', 'mi-zhu', 'zhuge-liang', 'zhao-yun']; const fanCastleVanguardSortie = ['liu-bei', 'guan-yu', 'zhang-fei', 'zhao-yun', 'huang-zhong', 'fa-zheng', 'ma-liang']; const hanRiverFloodSortie = ['liu-bei', 'guan-yu', 'fa-zheng', 'ma-liang', 'huang-quan', 'zhao-yun', 'ma-chao']; const fanCastleSiegeSortie = ['liu-bei', 'guan-yu', 'huang-zhong', 'ma-liang', 'fa-zheng', 'zhao-yun', 'ma-chao']; const jingRearSortie = ['liu-bei', 'guan-yu', 'ma-liang', 'mi-zhu', 'zhao-yun', 'huang-quan', 'wang-ping']; const gonganCollapseSortie = ['liu-bei', 'guan-yu', 'ma-liang', 'mi-zhu', 'huang-quan', 'wang-ping', 'zhao-yun']; const maichengSortie = ['liu-bei', 'guan-yu', 'wang-ping', 'zhao-yun', 'huang-zhong', 'ma-dai', 'ma-liang']; const yilingVanguardSortie = ['liu-bei', 'zhang-fei', 'zhao-yun', 'ma-chao', 'zhuge-liang', 'huang-quan', 'ma-liang']; const yilingFireSortie = ['liu-bei', 'huang-quan', 'zhuge-liang', 'ma-liang', 'zhao-yun', 'wang-ping', 'ma-chao']; const nanzhongStabilizationSortie = ['zhuge-liang', 'zhao-yun', 'wang-ping', 'ma-liang', 'huang-quan', 'huang-zhong', 'ma-chao']; const mengHuoMainSortie = ['zhuge-liang', 'ma-liang', 'wang-ping', 'huang-quan', 'zhao-yun', 'ma-chao', 'huang-zhong']; const mengHuoSecondSortie = ['zhuge-liang', 'huang-quan', 'wang-ping', 'ma-liang', 'zhao-yun', 'huang-zhong', 'ma-chao']; const mengHuoThirdSortie = ['zhuge-liang', 'ma-liang', 'huang-quan', 'wang-ping', 'zhao-yun', 'ma-chao', 'huang-zhong']; const mengHuoFourthSortie = ['zhuge-liang', 'ma-liang', 'huang-quan', 'wang-ping', 'zhao-yun', 'ma-chao', 'ma-dai']; const mengHuoFifthSortie = ['zhuge-liang', 'huang-quan', 'ma-liang', 'wang-ping', 'zhao-yun', 'huang-zhong', 'ma-dai']; const mengHuoSixthSortie = ['zhuge-liang', 'ma-liang', 'huang-quan', 'wang-ping', 'zhao-yun', 'wei-yan', 'ma-dai']; const mengHuoFinalSortie = ['zhuge-liang', 'huang-quan', 'ma-liang', 'wang-ping', 'zhao-yun', 'ma-dai', 'huang-zhong']; const earlyCampaignBattles = [ { no: 1, id: 'first-battle-zhuo-commandery', selected: [], targets: ['rebel-leader'], protected: ['liu-bei'] }, { no: 2, id: 'second-battle-yellow-turban-pursuit', selected: coreBrothers, targets: ['pursuit-leader-han-seok'], protected: ['liu-bei'] }, { no: 3, id: 'third-battle-guangzong-road', selected: coreBrothers, targets: ['guangzong-leader-ma-yuan'], protected: ['liu-bei'] }, { no: 4, id: 'fourth-battle-guangzong-camp', selected: coreBrothers, targets: ['guangzong-main-leader-zhang-jue'], protected: ['liu-bei'] }, { no: 5, id: 'fifth-battle-sishui-vanguard', selected: coreBrothers, targets: ['sishui-leader-hu-zhen'], protected: ['liu-bei'] }, { no: 6, id: 'sixth-battle-jieqiao-relief', selected: coreBrothers, targets: ['jieqiao-leader-qu-yi'], protected: ['liu-bei'] }, { no: 7, id: 'seventh-battle-xuzhou-rescue', selected: coreBrothers, targets: ['xuzhou-leader-xiahou-dun'], protected: ['liu-bei'] }, { no: 8, id: 'eighth-battle-xiaopei-supply-road', selected: xuzhouFullSortie, targets: ['xiaopei-leader-gao-shun'], protected: ['liu-bei'] }, { no: 9, id: 'ninth-battle-xuzhou-gate-night-raid', selected: xuzhouFullSortie, targets: ['xuzhou-leader-cao-bao'], protected: ['liu-bei'] }, { no: 10, id: 'tenth-battle-xuzhou-breakout', selected: xuzhouFullSortie, targets: ['xuzhou-escape-leader-song-xian'], protected: ['liu-bei'] } ]; const refugeCampaignBattles = [ { no: 11, id: 'eleventh-battle-xudu-refuge-road', selected: caoRefugeSortie, targets: ['xudu-road-leader-ji-ling'], protected: ['liu-bei'] }, { no: 12, id: 'twelfth-battle-xiapi-outer-siege', selected: caoRefugeSortie, targets: ['xiapi-leader-zhang-liao'], protected: ['liu-bei'] }, { no: 13, id: 'thirteenth-battle-xiapi-final', selected: caoRefugeSortie, targets: ['xiapi-final-leader-lu-bu'], protected: ['liu-bei'] }, { no: 14, id: 'fourteenth-battle-cao-break', selected: sunQianSortie, targets: ['cao-break-leader-che-zhou'], protected: ['liu-bei'] }, { no: 15, id: 'fifteenth-battle-yuan-refuge-road', selected: sunQianSortie, targets: ['yuan-refuge-leader-cai-yang'], protected: ['liu-bei'] }, { no: 16, id: 'sixteenth-battle-liu-biao-refuge', selected: liuBiaoSortie, targets: ['liu-biao-road-leader-cao-ren'], protected: ['liu-bei'] }, { no: 17, id: 'seventeenth-battle-wolong-visit-road', selected: liuBiaoSortie, targets: ['wolong-road-leader-cai-xun'], protected: ['liu-bei'] }, { no: 18, id: 'eighteenth-battle-bowang-ambush', selected: zhugeFirstSortie, targets: ['bowang-leader-xiahou-dun'], protected: ['liu-bei'] }, { no: 19, id: 'nineteenth-battle-changban-refuge', selected: zhugeFirstSortie, targets: ['changban-leader-cao-chun'], protected: ['liu-bei', 'zhao-yun'] }, { no: 20, id: 'twentieth-battle-jiangdong-envoy', selected: envoySortie, targets: ['envoy-road-leader-wen-pin'], protected: ['liu-bei', 'zhuge-liang', 'sun-qian'] } ]; const southernCampaignBattles = [ { no: 21, id: 'twenty-first-battle-red-cliffs-vanguard', selected: redCliffVanguardSortie, targets: ['redcliff-vanguard-leader-cai-mao'], protected: ['liu-bei', 'zhuge-liang'] }, { no: 22, id: 'twenty-second-battle-red-cliffs-fire', selected: redCliffFireSortie, targets: ['redcliff-fire-leader-cao-cao'], protected: ['liu-bei', 'zhuge-liang'] }, { no: 23, id: 'twenty-third-battle-jingzhou-south-entry', selected: jingSouthSortie, targets: ['jing-south-leader-xing-daorong'], protected: ['liu-bei', 'zhuge-liang'] }, { no: 24, id: 'twenty-fourth-battle-guiyang-persuasion', selected: guiyangSortie, targets: ['guiyang-leader-zhao-fan'], protected: ['liu-bei', 'zhuge-liang'] }, { no: 25, id: 'twenty-fifth-battle-wuling-mountain-road', selected: wulingSortie, targets: ['wuling-leader-jin-xuan'], protected: ['liu-bei', 'zhuge-liang'] }, { no: 26, id: 'twenty-sixth-battle-changsha-veteran', selected: changshaSortie, targets: ['changsha-leader-han-xuan'], protected: ['liu-bei', 'zhuge-liang'] }, { no: 27, id: 'twenty-seventh-battle-yizhou-relief-road', selected: yizhouReliefSortie, targets: ['yizhou-leader-yang-huai'], protected: ['liu-bei', 'zhuge-liang'] }, { no: 28, id: 'twenty-eighth-battle-fu-pass-entry', selected: fuPassSortie, targets: ['fupass-leader-gao-pei'], protected: ['liu-bei', 'zhuge-liang'] }, { no: 29, id: 'twenty-ninth-battle-luo-outer-wall', selected: luoOuterSortie, targets: ['luo-leader-zhang-ren'], protected: ['liu-bei', 'zhuge-liang'] }, { no: 30, id: 'thirtieth-battle-luofeng-ambush', selected: luofengSortie, targets: ['luofeng-leader-zhang-ren'], protected: ['liu-bei', 'pang-tong'] } ]; const hanzhongCampaignBattles = [ { no: 31, id: 'thirty-first-battle-luo-main-gate', selected: luoMainSortie, targets: ['luo-main-leader-zhang-ren'], protected: ['liu-bei', 'pang-tong'] }, { no: 32, id: 'thirty-second-battle-mianzhu-gate', selected: mianzhuSortie, targets: ['chengdu-leader-li-yan'], protected: ['liu-bei', 'yan-yan'] }, { no: 33, id: 'thirty-third-battle-chengdu-surrender', selected: chengduSortie, targets: ['chengdu-final-leader-huang-quan'], protected: ['liu-bei', 'li-yan'] }, { no: 34, id: 'thirty-fourth-battle-jiameng-pass', selected: jiamengSortie, targets: ['jiameng-leader-ma-chao'], protected: ['liu-bei', 'huang-quan'] }, { no: 35, id: 'thirty-fifth-battle-yangping-scout', selected: yangpingSortie, targets: ['yangping-leader-zhang-wei'], protected: ['liu-bei', 'ma-chao'] }, { no: 36, id: 'thirty-sixth-battle-dingjun-vanguard', selected: dingjunSortie, targets: ['dingjun-leader-xiahou-yuan'], protected: ['liu-bei', 'huang-zhong'] }, { no: 37, id: 'thirty-seventh-battle-hanzhong-decisive', selected: hanzhongSortie, targets: ['hanzhong-leader-cao-cao'], protected: ['liu-bei', 'wang-ping'] }, { no: 38, id: 'thirty-eighth-battle-jing-defense', selected: jingDefenseSortie, targets: ['jing-defense-leader-lu-meng'], protected: ['liu-bei', 'guan-yu'] }, { no: 39, id: 'thirty-ninth-battle-fan-castle-vanguard', selected: fanCastleVanguardSortie, targets: ['fan-castle-leader-pang-de'], protected: ['liu-bei', 'guan-yu'] }, { no: 40, id: 'fortieth-battle-han-river-flood', selected: hanRiverFloodSortie, targets: ['han-river-leader-yu-jin'], protected: ['liu-bei', 'guan-yu'] } ]; const yilingCampaignBattles = [ { no: 41, id: 'forty-first-battle-fan-castle-siege', selected: fanCastleSiegeSortie, targets: ['fan-siege-leader-cao-ren'], protected: ['liu-bei', 'guan-yu'] }, { no: 42, id: 'forty-second-battle-jing-rear-crisis', selected: jingRearSortie, targets: ['jing-rear-leader-lu-meng'], protected: ['liu-bei', 'guan-yu'] }, { no: 43, id: 'forty-third-battle-gongan-collapse', selected: gonganCollapseSortie, targets: ['jing-collapse-leader-lu-meng'], protected: ['liu-bei', 'guan-yu'] }, { no: 44, id: 'forty-fourth-battle-maicheng-isolation', selected: maichengSortie, targets: ['maicheng-leader-lu-meng'], protected: ['liu-bei', 'guan-yu'] }, { no: 45, id: 'forty-fifth-battle-yiling-vanguard', selected: yilingVanguardSortie, targets: ['yiling-leader-lu-xun'], protected: ['liu-bei'] }, { no: 46, id: 'forty-sixth-battle-yiling-fire', selected: yilingFireSortie, targets: ['yiling-fire-leader-lu-xun'], protected: ['liu-bei'] }, { no: 47, id: 'forty-seventh-battle-nanzhong-stabilization', selected: nanzhongStabilizationSortie, targets: ['nanzhong-leader-yong-kai'], protected: ['zhuge-liang'] }, { no: 48, id: 'forty-eighth-battle-meng-huo-main-force', selected: mengHuoMainSortie, targets: ['menghuo-leader'], protected: ['zhuge-liang'] }, { no: 49, id: 'forty-ninth-battle-meng-huo-second-capture', selected: mengHuoSecondSortie, targets: ['menghuo-second-leader'], protected: ['zhuge-liang'] }, { no: 50, id: 'fiftieth-battle-meng-huo-third-capture', selected: mengHuoThirdSortie, targets: ['menghuo-third-leader'], protected: ['zhuge-liang'] } ]; const nanzhongFinalCampaignBattles = [ { no: 51, id: 'fifty-first-battle-meng-huo-fourth-capture', selected: mengHuoFourthSortie, targets: ['menghuo-fourth-leader'], protected: ['zhuge-liang'] }, { no: 52, id: 'fifty-second-battle-meng-huo-fifth-capture', selected: mengHuoFifthSortie, targets: ['menghuo-fifth-leader'], protected: ['zhuge-liang'] }, { no: 53, id: 'fifty-third-battle-meng-huo-sixth-capture', selected: mengHuoSixthSortie, targets: ['menghuo-sixth-leader'], protected: ['zhuge-liang'] }, { no: 54, id: 'fifty-fourth-battle-meng-huo-final-capture', selected: mengHuoFinalSortie, targets: ['menghuo-final-leader'], protected: ['zhuge-liang'] } ]; const allRepresentativeBattles = [ { no: 1, id: 'first-battle-zhuo-commandery', selected: [], targets: ['rebel-leader'], protected: ['liu-bei'] }, { no: 7, id: 'seventh-battle-xuzhou-rescue', selected: ['liu-bei', 'guan-yu', 'zhang-fei'], targets: ['xuzhou-leader-xiahou-dun'], protected: ['liu-bei'] }, { no: 18, id: 'eighteenth-battle-bowang-ambush', selected: ['liu-bei', 'zhuge-liang', 'zhao-yun', 'guan-yu', 'zhang-fei', 'sun-qian'], targets: ['bowang-leader-xiahou-dun'], protected: ['liu-bei'] }, { no: 27, id: 'twenty-seventh-battle-yizhou-relief-road', selected: ['liu-bei', 'zhuge-liang', 'huang-zhong', 'wei-yan', 'zhao-yun', 'mi-zhu'], targets: ['yizhou-leader-yang-huai'], protected: ['liu-bei', 'zhuge-liang'] }, { no: 37, id: 'thirty-seventh-battle-hanzhong-decisive', selected: ['liu-bei', 'huang-zhong', 'fa-zheng', 'wang-ping', 'ma-chao', 'ma-dai', 'zhuge-liang'], targets: ['hanzhong-leader-cao-cao'], protected: ['liu-bei', 'wang-ping'] }, { no: 45, id: 'forty-fifth-battle-yiling-vanguard', selected: ['liu-bei', 'zhang-fei', 'zhao-yun', 'ma-chao', 'zhuge-liang', 'huang-quan', 'ma-liang'], targets: ['yiling-leader-lu-xun'], protected: ['liu-bei'] }, { no: 55, id: 'fifty-fifth-battle-northern-qishan-road', selected: ['zhuge-liang', 'zhao-yun', 'huang-quan', 'ma-liang', 'wang-ping', 'ma-dai', 'wei-yan'], targets: ['northern-first-leader-cao-zhen'], protected: ['zhuge-liang'] }, { no: 58, id: 'fifty-eighth-battle-qishan-retreat', selected: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'zhao-yun', 'ma-dai', 'huang-quan', 'ma-liang'], targets: ['northern-fourth-leader-sima-yi', 'northern-fourth-officer-zhang-he'], protected: ['zhuge-liang'] }, { no: 66, id: 'sixty-sixth-battle-wuzhang-final', selected: ['zhuge-liang', 'jiang-wei', 'wang-ping', 'ma-dai', 'huang-quan', 'li-yan', 'wei-yan'], targets: ['northern-twelfth-leader-sima-yi', 'northern-twelfth-officer-zhang-he', 'northern-twelfth-officer-guo-huai'], protected: ['zhuge-liang'] } ]; const qaBattleSets = { representative: allRepresentativeBattles, early: earlyCampaignBattles, refuge: refugeCampaignBattles, southern: southernCampaignBattles, hanzhong: hanzhongCampaignBattles, yiling: yilingCampaignBattles, nanzhongFinal: nanzhongFinalCampaignBattles }; const qaSetName = process.env.QA_SET ?? 'representative'; const qaBattleSet = qaBattleSets[qaSetName]; if (!qaBattleSet) { throw new Error(`Unknown QA_SET "${qaSetName}". Use one of: ${Object.keys(qaBattleSets).join(', ')}`); } const requestedBattles = new Set( (process.env.QA_BATTLES ?? '') .split(',') .map((entry) => entry.trim()) .filter(Boolean) ); const representativeBattles = requestedBattles.size > 0 ? qaBattleSet.filter((battle) => requestedBattles.has(String(battle.no)) || requestedBattles.has(battle.id)) : qaBattleSet; let serverProcess; try { serverProcess = await ensureLocalServer(targetUrl); const browser = await chromium.launch({ headless }); try { const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); const results = []; for (const battle of representativeBattles) { console.log(`QA battle ${battle.no}: ${battle.id}`); await setupBattle(page, battle); const result = await playBattleWithoutDebugVictory(page, battle); results.push(result); console.log( `QA result ${result.no}: ${result.outcome} turn=${result.finalTurn} allies=${result.alliesAlive} enemies=${result.enemiesAlive} items=${result.itemsUsed} objectives=${result.objectivesAchieved}/${result.objectiveCount}` ); } const failed = results.filter((result) => result.outcome !== 'victory'); console.table( results.map((result) => ({ no: result.no, outcome: result.outcome, turn: result.finalTurn, alliesAlive: result.alliesAlive, enemiesAlive: result.enemiesAlive, itemsUsed: result.itemsUsed, supports: result.supports, objectives: `${result.objectivesAchieved}/${result.objectiveCount}` })) ); if (failed.length > 0) { console.dir( failed.map((result) => ({ no: result.no, id: result.id, outcome: result.outcome, protected: result.protectedStatus, targets: result.targetStatus, allies: result.allyStatus, enemies: result.enemyStatus, log: result.battleLogTail })), { depth: null } ); throw new Error(`Representative battle QA failed: ${failed.map((result) => `${result.no}:${result.id}`).join(', ')}`); } console.log(`Representative battle QA passed without debug victory for ${results.length} battles at ${targetUrl}`); } finally { await browser.close(); } } finally { if (serverProcess) { serverProcess.kill(); } } async function setupBattle(page, battle) { await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 }); await page.evaluate((selected) => { const now = new Date().toISOString(); const state = { version: 1, updatedAt: now, step: 'first-battle', activeSaveSlot: 1, gold: 999999, roster: [], bonds: [], inventory: {}, selectedSortieUnitIds: selected, reserveTrainingFocus: 'balanced', completedCampDialogues: [], completedCampVisits: [], battleHistory: {} }; window.localStorage.clear(); window.localStorage.setItem('heros-web:campaign-state', JSON.stringify(state)); window.localStorage.setItem('heros-web:campaign-state:slot-1', JSON.stringify(state)); }, battle.selected); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 }); await page.evaluate((battleId) => window.__HEROS_DEBUG__.goToBattle(battleId), battle.id); await page.waitForFunction( (battleId) => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === battleId && state?.phase === 'idle' && state?.mapBackgroundReady === true; }, battle.id, { timeout: 90000 } ); } async function playBattleWithoutDebugVictory(page, battle) { return page.evaluate( async ({ no, id, maxRounds, targets, protected: protectedUnitIds }) => { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const scene = window.__HEROS_GAME__.scene.getScene('BattleScene'); const primaryTargetId = targets[0]; const targetIds = new Set(targets); const protectedIds = new Set(protectedUnitIds); const metrics = { no, id, outcome: null, finalTurn: 0, alliesAlive: 0, enemiesAlive: 0, itemsUsed: 0, supports: 0, allyAttacks: 0, enemyAttacks: 0, objectivesAchieved: 0, objectiveCount: 0, protectedStatus: [], targetStatus: [], allyStatus: [], enemyStatus: [], battleLogTail: [] }; const state = () => window.__HEROS_DEBUG__.battle(); const distance = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y); const unitRef = (unitId) => scene.debugUnitById(unitId); const liveUnits = (faction) => state() .units.filter((unit) => unit.faction === faction && unit.hp > 0) .map((unit) => unitRef(unit.id)) .filter(Boolean); const actedUnitIds = () => new Set(state().actedUnitIds ?? []); const syncUnit = (unit) => { scene.positionUnitView?.(unit); scene.updateMiniMap?.(); }; const resolveOutcome = () => scene.resolveBattleOutcomeIfNeeded?.(); function withUnitAt(unit, tile, callback) { const original = { x: unit.x, y: unit.y }; unit.x = tile.x; unit.y = tile.y; try { return callback(); } finally { unit.x = original.x; unit.y = original.y; } } function priorityForTarget(enemy) { if (enemy.id === primaryTargetId) { return 5200; } if (targetIds.has(enemy.id)) { return 3400; } if (enemy.id.includes('leader')) { return 1200; } if (enemy.id.includes('officer')) { return 720; } return 0; } function focusTarget(unit) { const enemies = liveUnits('enemy'); const canProgressTo = (enemy) => { if (scene.canAttack(unit, enemy)) { return true; } const approachTile = scene.chooseApproachTile?.(unit, enemy); return Boolean(approachTile && (approachTile.x !== unit.x || approachTile.y !== unit.y)); }; const progressEnemies = enemies.filter(canProgressTo); const nearestEnemy = progressEnemies.sort((a, b) => distance(unit, a) - distance(unit, b) || priorityForTarget(b) - priorityForTarget(a))[0]; const aliveTargets = targets.map((targetId) => unitRef(targetId)).filter((target) => target && target.hp > 0); if (aliveTargets.length > 0) { const reachableTargets = aliveTargets.filter(canProgressTo); const nearestTarget = (reachableTargets.length > 0 ? reachableTargets : aliveTargets).sort((a, b) => distance(unit, a) - distance(unit, b))[0]; if (nearestEnemy && nearestEnemy.id !== nearestTarget.id && distance(unit, nearestEnemy) + 5 < distance(unit, nearestTarget)) { return nearestEnemy; } return nearestTarget; } return (progressEnemies.length > 0 ? progressEnemies : enemies).sort( (a, b) => priorityForTarget(b) - priorityForTarget(a) || distance(unit, a) - distance(unit, b) )[0]; } function attackChoice(unit) { return liveUnits('enemy') .filter((enemy) => scene.canAttack(unit, enemy)) .map((enemy) => { const preview = scene.combatPreview(unit, enemy, 'attack'); const kill = preview.damage >= enemy.hp; const priority = priorityForTarget(enemy); return { target: enemy, preview, score: priority + preview.damage * 10 + (kill ? 760 : 0) - enemy.hp - distance(unit, enemy) * 4 }; }) .sort((a, b) => b.score - a.score)[0]; } function incomingThreat(unit, tile) { return withUnitAt(unit, tile, () => { const threats = []; const nearbyEnemies = liveUnits('enemy') .sort((a, b) => distance(a, tile) - distance(b, tile)) .slice(0, 10); for (const enemy of nearbyEnemies) { const original = { x: enemy.x, y: enemy.y }; const behavior = scene.enemyBehavior(enemy); const target = scene.chooseEnemyTarget(enemy, behavior); if (target) { if (!scene.canAttack(enemy, target) && behavior !== 'hold') { const destination = scene.chooseApproachTile(enemy, target); if (destination) { enemy.x = destination.x; enemy.y = destination.y; } } if (scene.canAttack(enemy, unit)) { threats.push(scene.combatPreview(enemy, unit, 'attack').damage); } } enemy.x = original.x; enemy.y = original.y; } return threats.sort((a, b) => b - a).slice(0, 3).reduce((total, damage) => total + damage, 0); }); } function shouldPressProtected(unit) { if (!protectedIds.has(unit.id)) { return false; } const focus = focusTarget(unit); const enemies = liveUnits('enemy'); const vanguards = liveUnits('ally').filter((ally) => ally.id !== unit.id && !protectedIds.has(ally.id)); const vanguardInPosition = vanguards.some((ally) => !focus || distance(ally, focus) <= 10); if (vanguards.length === 0) { return unit.hp >= Math.ceil(unit.maxHp * 0.8); } const targetPressThreshold = protectedIds.size > 1 ? 0.12 : 0.3; return Boolean( vanguardInPosition && focus && targetIds.has(focus.id) && focus.hp <= Math.ceil(focus.maxHp * targetPressThreshold) && unit.hp >= Math.ceil(unit.maxHp * 0.7) ); } function canTakeAttack(unit, attack, threat = protectedIds.has(unit.id) ? incomingThreat(unit, unit) : 0) { if (!protectedIds.has(unit.id)) { return true; } const pressProtected = shouldPressProtected(unit); const isTarget = targetIds.has(attack.target.id); const kill = attack.preview.damage >= attack.target.hp; if (protectedIds.size > 1 && !isTarget && unit.id === 'sun-qian') { return kill && liveUnits('enemy').length <= 3 && threat <= Math.floor(unit.hp * 0.12); } if (protectedIds.size > 1 && !isTarget) { return unit.hp >= Math.ceil(unit.maxHp * 0.75) && threat <= Math.floor(unit.hp * 0.22); } if (isTarget && (pressProtected || kill) && threat <= Math.floor(unit.hp * (pressProtected ? 0.7 : 0.42))) { return true; } if (isTarget && protectedIds.size === 1 && threat <= Math.floor(unit.hp * 0.3)) { return true; } if (kill && threat <= Math.floor(unit.hp * (pressProtected ? 0.5 : 0.32))) { return true; } return threat <= Math.floor(unit.hp * (pressProtected ? 0.28 : 0.18)); } function canPressProtectedTile(unit, tile, focus, currentDistance) { const threat = incomingThreat(unit, tile); if (threat <= Math.floor(unit.hp * 0.55)) { return true; } const enemies = liveUnits('enemy'); return ( unit.hp >= Math.ceil(unit.maxHp * 0.95) && distance(tile, focus) < currentDistance && (enemies.length <= 8 || currentDistance <= 8 || (targetIds.has(focus.id) && focus.hp <= Math.ceil(focus.maxHp * 0.5))) ); } function chooseMove(unit) { const tiles = [{ x: unit.x, y: unit.y, cost: 0 }, ...scene.reachableTiles(unit)]; const pressProtected = shouldPressProtected(unit); const attackingTiles = tiles .map((tile) => withUnitAt(unit, tile, () => { const attack = attackChoice(unit); return attack ? { tile, attack, threat: protectedIds.has(unit.id) ? incomingThreat(unit, tile) : 0 } : undefined; }) ) .filter(Boolean); const preferredAttackingTiles = protectedIds.has(unit.id) ? attackingTiles.filter((entry) => canTakeAttack(unit, entry.attack, entry.threat)) : attackingTiles; if (preferredAttackingTiles.length > 0 && (!protectedIds.has(unit.id) || unit.hp >= Math.ceil(unit.maxHp * 0.45))) { return preferredAttackingTiles.sort((a, b) => { const threatWeight = protectedIds.has(unit.id) ? (pressProtected ? 4 : 8) : 2; const aScore = a.attack.score + (targetIds.has(a.attack.target.id) ? 2400 : 1350) - a.threat * threatWeight; const bScore = b.attack.score + (targetIds.has(b.attack.target.id) ? 2400 : 1350) - b.threat * threatWeight; return bScore - aScore; })[0].tile; } if (!protectedIds.has(unit.id) || pressProtected) { const focus = focusTarget(unit); if (focus) { const currentDistance = distance(unit, focus); const approachTile = scene.chooseApproachTile?.(unit, focus); if ( approachTile && (approachTile.x !== unit.x || approachTile.y !== unit.y) && (!pressProtected || canPressProtectedTile(unit, approachTile, focus, currentDistance)) ) { return approachTile; } const pressureTile = tiles .filter((tile) => distance(tile, focus) < currentDistance) .filter((tile) => !pressProtected || canPressProtectedTile(unit, tile, focus, currentDistance)) .sort((a, b) => distance(a, focus) - distance(b, focus) || (a.cost ?? 0) - (b.cost ?? 0))[0]; if (pressureTile) { return pressureTile; } } } let best = { tile: tiles[0], score: Number.NEGATIVE_INFINITY }; for (const tile of tiles) { const score = withUnitAt(unit, tile, () => { const attack = attackChoice(unit); const focus = attack?.target ?? focusTarget(unit); const isProtected = protectedIds.has(unit.id); const shouldPress = isProtected && shouldPressProtected(unit); const needsThreatCheck = isProtected || unit.hp <= Math.ceil(unit.maxHp * 0.55); const threat = needsThreatCheck ? incomingThreat(unit, tile) : 0; const canUseAttack = attack && (!isProtected || canTakeAttack(unit, attack, threat)); const approach = focus ? -distance(unit, focus) * (isProtected ? (shouldPress ? 42 : 5) : 36) : 0; const attackScore = canUseAttack ? attack.score + (targetIds.has(attack.target.id) ? 2400 : 1350) : approach; const allySupport = liveUnits('ally').filter((ally) => ally.id !== unit.id && distance(unit, ally) <= 2).length * 16; const nearestEnemyDistance = liveUnits('enemy') .map((enemy) => distance(unit, enemy)) .sort((a, b) => a - b)[0] ?? 99; const protectedFrontlinePenalty = isProtected && !shouldPress && nearestEnemyDistance <= 5 ? (6 - nearestEnemyDistance) * 1800 : 0; const fragilePenalty = unit.hp - threat <= Math.max(5, Math.floor(unit.maxHp * 0.18)) ? (isProtected ? (shouldPress ? 420 : 900) : 260) : 0; const threatWeight = isProtected ? (shouldPress ? 5 : 12) : 4; return attackScore + allySupport - threat * threatWeight - fragilePenalty - protectedFrontlinePenalty - (tile.cost ?? 0) * 2; }); if (score > best.score) { best = { tile, score }; } } return best.tile; } function bestSupportAction(user) { const injured = liveUnits('ally') .filter((ally) => ally.hp < ally.maxHp) .sort((a, b) => b.maxHp - b.hp - (a.maxHp - a.hp)); const options = []; for (const command of ['item', 'strategy']) { for (const usable of scene.availableUsables(user, command)) { if (usable.effect !== 'heal') { continue; } for (const target of injured) { if (!scene.canUseSupportCommand(user, target, usable)) { continue; } const missing = target.maxHp - target.hp; const amount = scene.supportHealAmount(user, target, usable); const urgent = target.hp <= Math.ceil(target.maxHp * 0.45); if (!urgent && amount < 8 && missing < 10) { continue; } options.push({ usable, target, score: amount * 12 + missing * 4 + (urgent ? 180 : 0) + (usable.command === 'item' ? 15 : 0) }); } } } return options.sort((a, b) => b.score - a.score)[0]; } function performSupport(user, support) { if (support.usable.command === 'item' && !scene.consumeItem(user.id, support.usable.id)) { return false; } scene.resolveSupportAction(user, support.target, support.usable); scene.finishUnitAction(user, `QA support ${user.id} -> ${support.target.id}`); metrics.supports += 1; if (support.usable.command === 'item') { metrics.itemsUsed += 1; } resolveOutcome(); return true; } function performAttack(attacker, target, side) { const result = scene.resolveAttack(attacker, target); result.counter = scene.resolveCounterAttack(result); if (side === 'ally') { scene.finishUnitAction(attacker, `QA attack ${attacker.id} -> ${target.id}`); metrics.allyAttacks += 1; } else { metrics.enemyAttacks += 1; } resolveOutcome(); } for (let round = 0; round < maxRounds; round += 1) { let snapshot = state(); if (snapshot.battleOutcome) { break; } const acted = actedUnitIds(); const allies = snapshot.units .filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !acted.has(unit.id)) .sort((a, b) => a.hp / a.maxHp - b.hp / b.maxHp); for (const allyState of allies) { snapshot = state(); if (snapshot.battleOutcome) { break; } const unit = unitRef(allyState.id); if (!unit || unit.hp <= 0 || actedUnitIds().has(unit.id)) { continue; } const urgentSupport = bestSupportAction(unit); if (urgentSupport && (unit.hp <= Math.ceil(unit.maxHp * 0.48) || urgentSupport.target.hp <= Math.ceil(urgentSupport.target.maxHp * 0.4))) { if (performSupport(unit, urgentSupport)) { await sleep(1); continue; } } scene.selectedUnit = unit; scene.phase = 'moving'; const tile = chooseMove(unit); scene.moveSelectedUnit(tile.x, tile.y); syncUnit(unit); const attack = attackChoice(unit); if (attack && canTakeAttack(unit, attack)) { performAttack(unit, attack.target, 'ally'); } else { const support = bestSupportAction(unit); if (!support || !performSupport(unit, support)) { scene.completeUnitAction(unit, `QA wait ${unit.id}`); } } await sleep(1); } snapshot = state(); if (snapshot.battleOutcome) { break; } scene.activeFaction = 'enemy'; for (const enemyState of snapshot.units.filter((unit) => unit.faction === 'enemy' && unit.hp > 0)) { if (state().battleOutcome) { break; } const enemy = unitRef(enemyState.id); if (!enemy || enemy.hp <= 0) { continue; } const behavior = scene.enemyBehavior(enemy); let target = scene.chooseEnemyTarget(enemy, behavior); if (!target) { continue; } if (!scene.canAttack(enemy, target) && behavior !== 'hold') { const destination = scene.chooseApproachTile(enemy, target); if (destination) { enemy.x = destination.x; enemy.y = destination.y; syncUnit(enemy); } } target = scene.chooseTargetInRange(enemy) ?? target; if (target && scene.canAttack(enemy, target)) { performAttack(enemy, target, 'enemy'); } } if (state().battleOutcome) { break; } scene.beginNextAllyTurn(); await sleep(1); } const finalState = state(); metrics.outcome = finalState.battleOutcome; metrics.finalTurn = finalState.turnNumber; metrics.alliesAlive = finalState.units.filter((unit) => unit.faction === 'ally' && unit.hp > 0).length; metrics.enemiesAlive = finalState.units.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length; metrics.objectiveCount = finalState.objectives.length; metrics.objectivesAchieved = finalState.objectives.filter((objective) => objective.achieved).length; metrics.protectedStatus = protectedUnitIds.map((unitId) => { const unit = finalState.units.find((candidate) => candidate.id === unitId); return unit ? `${unit.id}:${unit.hp}/${unit.maxHp}@${unit.x},${unit.y}` : `${unitId}:missing`; }); metrics.targetStatus = targets.map((unitId) => { const unit = finalState.units.find((candidate) => candidate.id === unitId); return unit ? `${unit.id}:${unit.hp}/${unit.maxHp}@${unit.x},${unit.y}` : `${unitId}:missing`; }); metrics.allyStatus = finalState.units .filter((unit) => unit.faction === 'ally') .map((unit) => `${unit.id}:${unit.hp}/${unit.maxHp}@${unit.x},${unit.y}`); metrics.enemyStatus = finalState.units .filter((unit) => unit.faction === 'enemy' && unit.hp > 0) .map((unit) => `${unit.id}:${unit.hp}/${unit.maxHp}@${unit.x},${unit.y}`); metrics.battleLogTail = finalState.battleLog.slice(-8); return metrics; }, { no: battle.no, id: battle.id, maxRounds, targets: battle.targets, protected: battle.protected } ); } async function ensureLocalServer(url) { if (await isServerReachable(url)) { return undefined; } const child = spawn(pnpmCommand, ['dev'], { cwd: process.cwd(), shell: true, stdio: 'ignore', windowsHide: true }); for (let attempt = 0; attempt < 120; attempt += 1) { if (await isServerReachable(url)) { return child; } await sleep(500); } child.kill(); throw new Error(`Could not start dev server at ${url}`); } async function isServerReachable(url) { try { const response = await fetch(url); return response.ok; } catch { return false; } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }