Files
heros_web/scripts/qa-representative-battles.mjs

1681 lines
61 KiB
JavaScript

import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { chromium } from 'playwright';
const cliOptions = parseCliOptions(process.argv.slice(2));
const defaultQaPort = process.env.QA_PORT ?? '41737';
const targetUrl = process.env.QA_URL ?? `http://127.0.0.1:${defaultQaPort}/`;
const headless = process.env.QA_HEADLESS !== '0';
const maxRounds = Number(process.env.QA_MAX_ROUNDS ?? 80);
const cumulativeMode = process.env.QA_CUMULATIVE === '1';
const qaReportPath = cliOptions.report ?? process.env.QA_REPORT_PATH ?? 'dist/qa-representative-battles.json';
const campaignStorageKey = 'heros-web:campaign-state';
const campaignSlotStorageKey = 'heros-web:campaign-state:slot-1';
const campaignSnapshotPath = process.env.QA_CAMPAIGN_SNAPSHOT_PATH;
const resumeCampaignSnapshot = process.env.QA_RESUME_CAMPAIGN_SNAPSHOT === '1';
const supplyLabels = {
bean: '\uCF69',
salve: '\uC0C1\uCC98\uC57D',
wine: '\uD0C1\uC8FC'
};
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 northernFirstSortie = ['zhuge-liang', 'zhao-yun', 'huang-quan', 'ma-liang', 'wang-ping', 'ma-dai', 'wei-yan'];
const northernSecondSortie = ['zhuge-liang', 'zhao-yun', 'huang-quan', 'ma-liang', 'wang-ping', 'ma-dai', 'wei-yan'];
const northernThirdSortie = ['zhuge-liang', 'wang-ping', 'zhao-yun', 'huang-quan', 'ma-liang', 'ma-dai', 'wei-yan'];
const northernFourthSortie = ['zhuge-liang', 'jiang-wei', 'wang-ping', 'zhao-yun', 'ma-dai', 'huang-quan', 'ma-liang'];
const northernFifthSortie = ['zhuge-liang', 'jiang-wei', 'wei-yan', 'wang-ping', 'ma-dai', 'huang-quan', 'fa-zheng'];
const northernSixthSortie = ['zhuge-liang', 'jiang-wei', 'wei-yan', 'zhao-yun', 'wang-ping', 'huang-quan', 'ma-liang'];
const northernSeventhSortie = ['zhuge-liang', 'jiang-wei', 'wang-ping', 'huang-quan', 'li-yan', 'ma-liang', 'wei-yan'];
const northernEighthSortie = ['zhuge-liang', 'jiang-wei', 'wei-yan', 'zhao-yun', 'wang-ping', 'huang-quan', 'ma-liang'];
const northernNinthSortie = ['zhuge-liang', 'jiang-wei', 'zhao-yun', 'wei-yan', 'wang-ping', 'huang-quan', 'li-yan'];
const northernTenthSortie = ['zhuge-liang', 'jiang-wei', 'wang-ping', 'huang-quan', 'li-yan', 'wei-yan', 'ma-dai'];
const northernEleventhSortie = ['zhuge-liang', 'jiang-wei', 'ma-dai', 'wang-ping', 'huang-quan', 'li-yan', 'wei-yan'];
const northernFinalSortie = ['zhuge-liang', 'jiang-wei', 'wang-ping', 'ma-dai', 'huang-quan', 'li-yan', 'wei-yan'];
const earlyCampaignBattles = [
{ no: 1, id: 'first-battle-zhuo-commandery', selected: [], targets: ['rebel-archer-b', 'rebel-leader'], protected: ['liu-bei'] },
{
no: 2,
id: 'second-battle-yellow-turban-pursuit',
selected: coreBrothers,
targets: ['pursuit-guard-a', '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-guard-a', 'jieqiao-archer-c', 'jieqiao-leader-qu-yi'],
protected: ['liu-bei']
},
{
no: 7,
id: 'seventh-battle-xuzhou-rescue',
selected: coreBrothers,
targets: ['xuzhou-archer-b', 'xuzhou-guard-a', 'xuzhou-guard-b', 'xuzhou-archer-c', '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 northernCampaignBattles = [
{
no: 55,
id: 'fifty-fifth-battle-northern-qishan-road',
selected: northernFirstSortie,
targets: ['northern-first-leader-cao-zhen'],
protected: ['zhuge-liang']
},
{
no: 56,
id: 'fifty-sixth-battle-tianshui-advance',
selected: northernSecondSortie,
targets: ['northern-second-officer-jiang-wei', 'northern-second-leader-guo-huai'],
protected: ['zhuge-liang']
},
{
no: 57,
id: 'fifty-seventh-battle-jieting-crisis',
selected: northernThirdSortie,
targets: ['northern-third-officer-jiang-wei', 'northern-third-leader-zhang-he'],
protected: ['zhuge-liang', 'wang-ping']
},
{
no: 58,
id: 'fifty-eighth-battle-qishan-retreat',
selected: northernFourthSortie,
targets: ['northern-fourth-leader-sima-yi', 'northern-fourth-officer-zhang-he'],
protected: ['zhuge-liang']
},
{
no: 59,
id: 'fifty-ninth-battle-chencang-siege',
selected: northernFifthSortie,
targets: ['northern-fifth-leader-hao-zhao', 'northern-fifth-officer-wang-shuang'],
protected: ['zhuge-liang']
},
{
no: 60,
id: 'sixtieth-battle-wudu-yinping',
selected: northernSixthSortie,
targets: ['northern-sixth-leader-guo-huai', 'northern-sixth-officer-wudu', 'northern-sixth-officer-yinping'],
protected: ['zhuge-liang']
},
{
no: 61,
id: 'sixty-first-battle-hanzhong-rain-defense',
selected: northernSeventhSortie,
targets: ['northern-seventh-leader-cao-zhen', 'northern-seventh-officer-sima-yi', 'northern-seventh-officer-supply'],
protected: ['zhuge-liang']
},
{
no: 62,
id: 'sixty-second-battle-qishan-renewed-offensive',
selected: northernEighthSortie,
targets: ['northern-eighth-leader-sima-yi', 'northern-eighth-officer-zhang-he', 'northern-eighth-officer-guo-huai'],
protected: ['zhuge-liang']
},
{
no: 63,
id: 'sixty-third-battle-lucheng-pursuit',
selected: northernNinthSortie,
targets: ['northern-ninth-leader-sima-yi', 'northern-ninth-officer-zhang-he', 'northern-ninth-officer-guo-huai'],
protected: ['zhuge-liang']
},
{
no: 64,
id: 'sixty-fourth-battle-weishui-camps',
selected: northernTenthSortie,
targets: ['northern-tenth-leader-sima-yi', 'northern-tenth-officer-zhang-he', 'northern-tenth-officer-guo-huai'],
protected: ['zhuge-liang']
},
{
no: 65,
id: 'sixty-fifth-battle-weishui-northbank',
selected: northernEleventhSortie,
targets: ['northern-eleventh-officer-zhang-he', 'northern-eleventh-leader-sima-yi', 'northern-eleventh-officer-guo-huai'],
protected: ['zhuge-liang']
},
{
no: 66,
id: 'sixty-sixth-battle-wuzhang-final',
selected: northernFinalSortie,
targets: ['northern-twelfth-officer-zhang-he', 'northern-twelfth-leader-sima-yi', 'northern-twelfth-officer-guo-huai'],
protected: ['zhuge-liang']
}
];
const allCampaignBattles = [
...earlyCampaignBattles,
...refugeCampaignBattles,
...southernCampaignBattles,
...hanzhongCampaignBattles,
...yilingCampaignBattles,
...nanzhongFinalCampaignBattles,
...northernCampaignBattles
];
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 smokeBattles = allRepresentativeBattles.filter((battle) => [1, 7, 27].includes(battle.no));
const qaBattleSets = {
smoke: smokeBattles,
representative: allRepresentativeBattles,
campaign: allCampaignBattles,
early: earlyCampaignBattles,
refuge: refugeCampaignBattles,
southern: southernCampaignBattles,
hanzhong: hanzhongCampaignBattles,
yiling: yilingCampaignBattles,
nanzhongFinal: nanzhongFinalCampaignBattles,
northern: northernCampaignBattles
};
const qaSetName = cliOptions.set ?? 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(
(cliOptions.battles ?? 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}`);
const preparation = cumulativeMode ? await setupCumulativeBattle(page, battle, results.length === 0) : await setupBattle(page, battle);
const result = await playBattleWithoutDebugVictory(page, battle);
if (preparation) {
result.campSuppliesUsed = preparation.suppliesUsed;
}
if (cumulativeMode) {
result.campaign = await readCampaignSummary(page);
if (campaignSnapshotPath) {
await writeCampaignSnapshot(page, campaignSnapshotPath);
}
}
results.push(result);
const stockLine = result.campaign
? ` stock=${result.campaign.supplies.bean}/${result.campaign.supplies.salve}/${result.campaign.supplies.wine} gold=${result.campaign.gold} avgLv=${result.campaign.levelAvg}`
: '';
const missedObjectiveLine = result.objectiveDetails?.some((objective) => !objective.achieved)
? ` missed=${result.objectiveDetails.filter((objective) => !objective.achieved).map((objective) => objective.id).join(',')}`
: '';
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}${missedObjectiveLine}${stockLine}`
);
if (missedObjectiveLine) {
console.log(
`QA missed detail ${result.no}: objectives=${JSON.stringify(result.objectiveDetails)} allies=${result.allyStatus.join(' | ')} enemies=${result.enemyStatus.join(' | ')}`
);
}
if (result.outcome !== 'victory') {
break;
}
}
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,
campUsed: result.campSuppliesUsed
? `${result.campSuppliesUsed.bean}/${result.campSuppliesUsed.salve}/${result.campSuppliesUsed.wine}`
: '',
stock: result.campaign ? `${result.campaign.supplies.bean}/${result.campaign.supplies.salve}/${result.campaign.supplies.wine}` : '',
level: result.campaign ? `${result.campaign.levelMin}-${result.campaign.levelMax} (${result.campaign.levelAvg})` : '',
lowHp: result.campaign?.lowHp ?? '',
supports: result.supports,
objectives: `${result.objectivesAchieved}/${result.objectiveCount}`,
missed: result.objectiveDetails?.filter((objective) => !objective.achieved).map((objective) => objective.id).join(',') ?? ''
}))
);
if (cumulativeMode && results.length > 0) {
const latest = results.at(-1).campaign;
console.dir(
{
mode: 'cumulative',
battleCount: latest?.battleCount,
gold: latest?.gold,
supplies: latest?.supplies,
rosterCount: latest?.rosterCount,
levelRange: latest ? `${latest.levelMin}-${latest.levelMax}` : undefined,
levelAvg: latest?.levelAvg,
lowHp: latest?.lowHp,
topLevels: latest?.topLevels
},
{ depth: null }
);
}
await writeQaReport(qaReportPath, results, failed);
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 === 'deployment' || state?.phase === 'idle') &&
state?.mapBackgroundReady === true
);
},
battle.id,
{ timeout: 90000 }
);
await startDeploymentIfNeeded(page, battle.id);
}
async function setupCumulativeBattle(page, battle, firstBattle) {
const initialSnapshot =
firstBattle && resumeCampaignSnapshot && campaignSnapshotPath && existsSync(campaignSnapshotPath)
? await readFile(campaignSnapshotPath, 'utf8')
: undefined;
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 });
const preparation = await page.evaluate(
({ firstBattle, initialSnapshot, labels, protectedUnitIds, selected, slotKey, storageKey }) => {
const now = new Date().toISOString();
const defaultState = {
version: 1,
updatedAt: now,
step: 'first-battle',
activeSaveSlot: 1,
gold: 0,
roster: [],
bonds: [],
inventory: {},
selectedSortieUnitIds: selected,
reserveTrainingFocus: 'balanced',
completedCampDialogues: [],
completedCampVisits: [],
battleHistory: {}
};
if (firstBattle && !initialSnapshot) {
window.localStorage.clear();
}
const rawState = initialSnapshot ?? (firstBattle ? undefined : window.localStorage.getItem(slotKey) ?? window.localStorage.getItem(storageKey));
const state = rawState ? JSON.parse(rawState) : defaultState;
const suppliesUsed = { bean: 0, salve: 0, wine: 0 };
state.updatedAt = now;
state.activeSaveSlot = 1;
state.inventory = state.inventory ?? {};
state.roster = Array.isArray(state.roster) ? state.roster : [];
state.selectedSortieUnitIds = [...new Set(selected)];
state.reserveTrainingFocus = state.reserveTrainingFocus ?? 'balanced';
const selectedIds = new Set(selected);
const protectedIds = new Set(protectedUnitIds);
const healAmounts = {
[labels.bean]: 12,
[labels.salve]: 22,
[labels.wine]: 8
};
const consume = (label) => {
if ((state.inventory[label] ?? 0) <= 0) {
return false;
}
state.inventory[label] -= 1;
if (state.inventory[label] <= 0) {
delete state.inventory[label];
}
return true;
};
const applySupply = (unit, supplyId, label) => {
if (!consume(label)) {
return false;
}
unit.hp = Math.min(unit.maxHp, unit.hp + healAmounts[label]);
suppliesUsed[supplyId] += 1;
return true;
};
for (const unit of state.roster) {
if (!selectedIds.has(unit.id) && !protectedIds.has(unit.id)) {
continue;
}
if (typeof unit.hp !== 'number' || typeof unit.maxHp !== 'number' || unit.maxHp <= 0) {
continue;
}
const targetRatio = protectedIds.has(unit.id) ? 1 : 0.86;
const targetHp = Math.min(unit.maxHp, Math.ceil(unit.maxHp * targetRatio));
while (unit.hp < targetHp) {
const missing = targetHp - unit.hp;
const ratio = unit.hp / unit.maxHp;
if (missing >= 17 && (state.inventory[labels.salve] ?? 0) > 0) {
applySupply(unit, 'salve', labels.salve);
} else if (missing >= 9 && (state.inventory[labels.bean] ?? 0) > 0) {
applySupply(unit, 'bean', labels.bean);
} else if (ratio < 0.7 && missing >= 5 && (state.inventory[labels.wine] ?? 0) > 0) {
applySupply(unit, 'wine', labels.wine);
} else if (protectedIds.has(unit.id) && (state.inventory[labels.bean] ?? 0) > 0) {
applySupply(unit, 'bean', labels.bean);
} else {
break;
}
}
}
window.localStorage.setItem(storageKey, JSON.stringify(state));
window.localStorage.setItem(slotKey, JSON.stringify(state));
return {
suppliesUsed,
stock: {
bean: state.inventory[labels.bean] ?? 0,
salve: state.inventory[labels.salve] ?? 0,
wine: state.inventory[labels.wine] ?? 0
}
};
},
{
firstBattle,
initialSnapshot,
labels: supplyLabels,
protectedUnitIds: battle.protected,
selected: battle.selected,
slotKey: campaignSlotStorageKey,
storageKey: campaignStorageKey
}
);
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 === 'deployment' || state?.phase === 'idle') &&
state?.mapBackgroundReady === true
);
},
battle.id,
{ timeout: 90000 }
);
await startDeploymentIfNeeded(page, battle.id);
return preparation;
}
async function startDeploymentIfNeeded(page, battleId) {
const state = await page.evaluate((expectedBattleId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.battleId === expectedBattleId ? battle : undefined;
}, battleId);
if (state?.phase !== 'deployment') {
return;
}
await page.mouse.click(1085, 637);
await page.waitForFunction(
(expectedBattleId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.battleId === expectedBattleId && battle?.phase === 'idle';
},
battleId,
{ timeout: 30000 }
);
}
async function writeCampaignSnapshot(page, snapshotPath) {
const snapshot = await page.evaluate(
({ slotKey, storageKey }) => window.localStorage.getItem(slotKey) ?? window.localStorage.getItem(storageKey),
{ slotKey: campaignSlotStorageKey, storageKey: campaignStorageKey }
);
if (snapshot) {
await writeFile(snapshotPath, snapshot, 'utf8');
}
}
async function readCampaignSummary(page) {
return page.evaluate(
({ labels, slotKey, storageKey }) => {
const rawState = window.localStorage.getItem(slotKey) ?? window.localStorage.getItem(storageKey);
if (!rawState) {
return undefined;
}
const state = JSON.parse(rawState);
const allies = (state.roster ?? []).filter((unit) => unit.faction === 'ally');
const levels = allies.map((unit) => unit.level ?? 1);
const hpRatios = allies.map((unit) => {
const maxHp = typeof unit.maxHp === 'number' && unit.maxHp > 0 ? unit.maxHp : 1;
return (unit.hp ?? maxHp) / maxHp;
});
const levelMin = levels.length > 0 ? Math.min(...levels) : 0;
const levelMax = levels.length > 0 ? Math.max(...levels) : 0;
const levelAvg = levels.length > 0 ? Number((levels.reduce((sum, level) => sum + level, 0) / levels.length).toFixed(1)) : 0;
return {
step: state.step,
latestBattleId: state.latestBattleId,
battleCount: Object.keys(state.battleHistory ?? {}).length,
gold: state.gold ?? 0,
supplies: {
bean: state.inventory?.[labels.bean] ?? 0,
salve: state.inventory?.[labels.salve] ?? 0,
wine: state.inventory?.[labels.wine] ?? 0
},
rosterCount: allies.length,
levelMin,
levelMax,
levelAvg,
lowHp: hpRatios.filter((ratio) => ratio < 0.55).length,
topLevels: allies
.map((unit) => ({ id: unit.id, level: unit.level ?? 1 }))
.sort((a, b) => b.level - a.level || a.id.localeCompare(b.id))
.slice(0, 8)
};
},
{ labels: supplyLabels, slotKey: campaignSlotStorageKey, storageKey: campaignStorageKey }
);
}
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,
objectiveDetails: [],
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?.();
const activeSecureObjectives = () =>
(state().objectives ?? []).filter((objective) => {
return objective.category === 'bonus' && objective.status !== 'done' && objective.targetTile && objective.id !== 'quick';
});
const objectiveDistance = (unitOrTile, objective) =>
Math.abs(unitOrTile.x - objective.targetTile.x) + Math.abs(unitOrTile.y - objective.targetTile.y);
const objectiveRadius = (objective) => objective.targetTile.radius ?? 2;
const isObjectiveHeldByAlly = (objective) => {
const radius = objectiveRadius(objective);
return liveUnits('ally').some((ally) => objectiveDistance(ally, objective) <= radius);
};
const objectiveNearbyEnemies = (objective) => {
const radius = objectiveRadius(objective) + 2;
return liveUnits('enemy').filter((enemy) => objectiveDistance(enemy, objective) <= radius);
};
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) {
const objective = activeSecureObjectives()[0];
const objectivePriority = objective && objectiveDistance(enemy, objective) <= objectiveRadius(objective) + 2 ? 1800 : 0;
if (enemy.id === primaryTargetId) {
return 5200 + objectivePriority;
}
if (targetIds.has(enemy.id)) {
return 3400 + objectivePriority;
}
if (enemy.id.includes('leader')) {
return 1200 + objectivePriority;
}
if (enemy.id.includes('officer')) {
return 720 + objectivePriority;
}
return objectivePriority;
}
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 objective = activeSecureObjectives()[0];
if (objective) {
const objectiveEnemies = objectiveNearbyEnemies(objective).filter(canProgressTo);
if (objectiveEnemies.length > 0) {
return objectiveEnemies.sort((a, b) => {
const aLeaderPenalty = a.id === primaryTargetId ? 4 : 0;
const bLeaderPenalty = b.id === primaryTargetId ? 4 : 0;
return objectiveDistance(a, objective) + aLeaderPenalty - (objectiveDistance(b, objective) + bLeaderPenalty) || distance(unit, a) - distance(unit, b);
})[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)) {
const kill = attack.preview.damage >= attack.target.hp;
const hpRate = unit.hp / Math.max(1, unit.maxHp);
if (unit.hp <= Math.ceil(unit.maxHp * 0.35)) {
return kill && threat <= Math.floor(unit.hp * 0.35);
}
if (hpRate <= 0.55) {
return threat <= Math.floor(unit.hp * 0.55) && (kill || attack.preview.damage >= Math.ceil(attack.target.hp * 0.35));
}
if (hpRate <= 0.75) {
return threat <= Math.floor(unit.hp * (kill ? 0.75 : 0.45));
}
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);
const needsThreatCheck = protectedIds.has(unit.id) || unit.hp <= Math.ceil(unit.maxHp * 0.75);
return attack ? { tile, attack, threat: needsThreatCheck ? incomingThreat(unit, tile) : 0 } : undefined;
})
)
.filter(Boolean);
const preferredAttackingTiles = attackingTiles.filter((entry) => canTakeAttack(unit, entry.attack, entry.threat));
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.75);
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.objectiveDetails = finalState.objectives.map((objective) => ({
id: objective.id,
achieved: objective.achieved,
status: objective.status,
category: objective.category,
summary: objective.summary,
detail: objective.detail,
failureReason: objective.failureReason ?? '',
targetTile: objective.targetTile ?? null
}));
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 viteArgs = localViteServerArgs(url);
if (!viteArgs) {
throw new Error(`QA target is not reachable and cannot be started locally: ${url}`);
}
const child = spawn(process.execPath, viteArgs, {
cwd: process.cwd(),
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}`);
}
function localViteServerArgs(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return undefined;
}
const localHosts = new Set(['localhost', '127.0.0.1', '0.0.0.0', '[::1]', '::1']);
if (!localHosts.has(parsed.hostname) || !parsed.port) {
return undefined;
}
const viteCliPath = 'node_modules/vite/bin/vite.js';
if (!existsSync(viteCliPath)) {
return undefined;
}
const host = parsed.hostname === 'localhost' || parsed.hostname === '0.0.0.0' ? '127.0.0.1' : parsed.hostname;
return [viteCliPath, '--host', host, '--port', parsed.port, '--strictPort'];
}
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));
}
function parseCliOptions(args) {
const options = {};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith('--')) {
continue;
}
const [rawKey, rawValue] = arg.slice(2).split('=', 2);
const key = rawKey.trim();
if (!key) {
continue;
}
if (rawValue !== undefined) {
options[key] = rawValue;
continue;
}
const next = args[index + 1];
if (next && !next.startsWith('--')) {
options[key] = next;
index += 1;
} else {
options[key] = '1';
}
}
return options;
}
async function writeQaReport(reportPath, results, failed) {
if (!reportPath) {
return;
}
await mkdir(dirname(reportPath), { recursive: true });
const report = buildQaReport(results, failed);
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
console.log(`Wrote QA report ${reportPath}`);
}
function buildQaReport(results, failed) {
const missedObjectives = results.flatMap((result) =>
(result.objectiveDetails ?? [])
.filter((objective) => !objective.achieved)
.map((objective) => ({
battleNo: result.no,
battleId: result.id,
objectiveId: objective.id,
category: objective.category,
summary: objective.summary,
detail: objective.detail,
failureReason: objective.failureReason ?? ''
}))
);
return {
app: 'heros_web',
generatedAt: new Date().toISOString(),
set: qaSetName,
targetUrl,
headless,
maxRounds,
cumulativeMode,
requestedBattles: [...requestedBattles],
battleCount: results.length,
summary: {
victories: results.filter((result) => result.outcome === 'victory').length,
failures: failed.length,
missedObjectiveCount: missedObjectives.length,
battlesWithMissedObjectives: [...new Set(missedObjectives.map((objective) => objective.battleNo))]
},
missedObjectives,
results: results.map((result) => ({
no: result.no,
id: result.id,
outcome: result.outcome,
finalTurn: result.finalTurn,
alliesAlive: result.alliesAlive,
enemiesAlive: result.enemiesAlive,
itemsUsed: result.itemsUsed,
supports: result.supports,
objectivesAchieved: result.objectivesAchieved,
objectiveCount: result.objectiveCount,
missedObjectives: (result.objectiveDetails ?? []).filter((objective) => !objective.achieved),
protectedStatus: result.protectedStatus,
targetStatus: result.targetStatus,
allyStatus: result.allyStatus,
enemyStatus: result.enemyStatus,
battleLogTail: result.battleLogTail,
campSuppliesUsed: result.campSuppliesUsed,
campaign: result.campaign
}))
};
}