3203 lines
118 KiB
JavaScript
3203 lines
118 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', 'mi-zhu'];
|
|
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 redCliffVanguardTargets = [
|
|
'redcliff-vanguard-scout-a',
|
|
'redcliff-vanguard-scout-b',
|
|
'redcliff-vanguard-marine-a',
|
|
'redcliff-vanguard-marine-b',
|
|
'redcliff-vanguard-marine-c',
|
|
'redcliff-vanguard-marine-d',
|
|
'redcliff-vanguard-archer-a',
|
|
'redcliff-vanguard-archer-b',
|
|
'redcliff-vanguard-archer-c',
|
|
'redcliff-vanguard-cavalry-a',
|
|
'redcliff-vanguard-cavalry-b',
|
|
'redcliff-vanguard-cavalry-c',
|
|
'redcliff-vanguard-guard-a',
|
|
'redcliff-vanguard-guard-b',
|
|
'redcliff-vanguard-strategist-a',
|
|
'redcliff-vanguard-strategist-b',
|
|
'redcliff-vanguard-leader-cai-mao'
|
|
];
|
|
|
|
const redCliffFireTargets = [
|
|
'redcliff-fire-scout-a',
|
|
'redcliff-fire-scout-b',
|
|
'redcliff-fire-marine-a',
|
|
'redcliff-fire-marine-b',
|
|
'redcliff-fire-marine-c',
|
|
'redcliff-fire-marine-d',
|
|
'redcliff-fire-archer-a',
|
|
'redcliff-fire-archer-b',
|
|
'redcliff-fire-archer-c',
|
|
'redcliff-fire-archer-d',
|
|
'redcliff-fire-cavalry-a',
|
|
'redcliff-fire-cavalry-b',
|
|
'redcliff-fire-cavalry-c',
|
|
'redcliff-fire-guard-a',
|
|
'redcliff-fire-guard-b',
|
|
'redcliff-fire-strategist-a',
|
|
'redcliff-fire-strategist-b',
|
|
'redcliff-fire-leader-cao-cao'
|
|
];
|
|
|
|
const jingSouthTargets = [
|
|
'jing-south-scout-a',
|
|
'jing-south-scout-b',
|
|
'jing-south-infantry-a',
|
|
'jing-south-infantry-b',
|
|
'jing-south-infantry-c',
|
|
'jing-south-infantry-d',
|
|
'jing-south-archer-a',
|
|
'jing-south-archer-b',
|
|
'jing-south-archer-c',
|
|
'jing-south-cavalry-a',
|
|
'jing-south-cavalry-b',
|
|
'jing-south-cavalry-c',
|
|
'jing-south-guard-a',
|
|
'jing-south-guard-b',
|
|
'jing-south-strategist-a',
|
|
'jing-south-strategist-b',
|
|
'jing-south-leader-xing-daorong'
|
|
];
|
|
|
|
const guiyangTargets = [
|
|
'guiyang-scout-a',
|
|
'guiyang-scout-b',
|
|
'guiyang-infantry-a',
|
|
'guiyang-infantry-b',
|
|
'guiyang-infantry-c',
|
|
'guiyang-infantry-d',
|
|
'guiyang-archer-a',
|
|
'guiyang-archer-b',
|
|
'guiyang-archer-c',
|
|
'guiyang-cavalry-a',
|
|
'guiyang-cavalry-b',
|
|
'guiyang-cavalry-c',
|
|
'guiyang-guard-a',
|
|
'guiyang-guard-b',
|
|
'guiyang-strategist-a',
|
|
'guiyang-strategist-b',
|
|
'guiyang-leader-zhao-fan'
|
|
];
|
|
|
|
const wulingTargets = [
|
|
'wuling-scout-a',
|
|
'wuling-scout-b',
|
|
'wuling-scout-c',
|
|
'wuling-bandit-a',
|
|
'wuling-bandit-b',
|
|
'wuling-bandit-c',
|
|
'wuling-infantry-a',
|
|
'wuling-infantry-b',
|
|
'wuling-infantry-c',
|
|
'wuling-infantry-d',
|
|
'wuling-archer-a',
|
|
'wuling-archer-b',
|
|
'wuling-archer-c',
|
|
'wuling-cavalry-a',
|
|
'wuling-cavalry-b',
|
|
'wuling-guard-a',
|
|
'wuling-guard-b',
|
|
'wuling-strategist-a',
|
|
'wuling-leader-jin-xuan'
|
|
];
|
|
|
|
const changshaTargets = [
|
|
'changsha-scout-a',
|
|
'changsha-scout-b',
|
|
'changsha-infantry-a',
|
|
'changsha-infantry-b',
|
|
'changsha-infantry-c',
|
|
'changsha-infantry-d',
|
|
'changsha-archer-a',
|
|
'changsha-archer-b',
|
|
'changsha-archer-c',
|
|
'changsha-cavalry-a',
|
|
'changsha-cavalry-b',
|
|
'changsha-guard-a',
|
|
'changsha-guard-b',
|
|
'changsha-strategist-a',
|
|
'changsha-strategist-b',
|
|
'changsha-veteran-huang-zhong',
|
|
'changsha-officer-wei-yan',
|
|
'changsha-leader-han-xuan'
|
|
];
|
|
|
|
const yizhouTargets = [
|
|
'yizhou-scout-a',
|
|
'yizhou-scout-b',
|
|
'yizhou-infantry-a',
|
|
'yizhou-infantry-b',
|
|
'yizhou-infantry-c',
|
|
'yizhou-infantry-d',
|
|
'yizhou-archer-a',
|
|
'yizhou-archer-b',
|
|
'yizhou-archer-c',
|
|
'yizhou-cavalry-a',
|
|
'yizhou-cavalry-b',
|
|
'yizhou-cavalry-c',
|
|
'yizhou-guard-a',
|
|
'yizhou-guard-b',
|
|
'yizhou-strategist-a',
|
|
'yizhou-strategist-b',
|
|
'yizhou-ambusher-a',
|
|
'yizhou-ambusher-b',
|
|
'yizhou-leader-yang-huai'
|
|
];
|
|
|
|
const fuPassTargets = [
|
|
'fupass-scout-a',
|
|
'fupass-scout-b',
|
|
'fupass-infantry-a',
|
|
'fupass-infantry-b',
|
|
'fupass-infantry-c',
|
|
'fupass-infantry-d',
|
|
'fupass-archer-a',
|
|
'fupass-archer-b',
|
|
'fupass-archer-c',
|
|
'fupass-cavalry-a',
|
|
'fupass-cavalry-b',
|
|
'fupass-cavalry-c',
|
|
'fupass-guard-a',
|
|
'fupass-guard-b',
|
|
'fupass-strategist-a',
|
|
'fupass-strategist-b',
|
|
'fupass-ambusher-a',
|
|
'fupass-ambusher-b',
|
|
'fupass-ambusher-c',
|
|
'fupass-leader-gao-pei'
|
|
];
|
|
|
|
const luoOuterTargets = [
|
|
'luo-scout-a',
|
|
'luo-scout-b',
|
|
'luo-infantry-a',
|
|
'luo-infantry-b',
|
|
'luo-infantry-c',
|
|
'luo-infantry-d',
|
|
'luo-archer-a',
|
|
'luo-archer-b',
|
|
'luo-archer-c',
|
|
'luo-cavalry-a',
|
|
'luo-cavalry-b',
|
|
'luo-cavalry-c',
|
|
'luo-guard-a',
|
|
'luo-guard-b',
|
|
'luo-strategist-a',
|
|
'luo-strategist-b',
|
|
'luo-ambusher-a',
|
|
'luo-ambusher-b',
|
|
'luo-officer-wu-yi',
|
|
'luo-leader-zhang-ren'
|
|
];
|
|
|
|
const luofengTargets = [
|
|
'luofeng-scout-a',
|
|
'luofeng-scout-b',
|
|
'luofeng-scout-c',
|
|
'luofeng-infantry-a',
|
|
'luofeng-infantry-b',
|
|
'luofeng-infantry-c',
|
|
'luofeng-infantry-d',
|
|
'luofeng-archer-a',
|
|
'luofeng-archer-b',
|
|
'luofeng-archer-c',
|
|
'luofeng-archer-d',
|
|
'luofeng-cavalry-a',
|
|
'luofeng-cavalry-b',
|
|
'luofeng-cavalry-c',
|
|
'luofeng-guard-a',
|
|
'luofeng-guard-b',
|
|
'luofeng-strategist-a',
|
|
'luofeng-strategist-b',
|
|
'luofeng-ambusher-a',
|
|
'luofeng-ambusher-b',
|
|
'luofeng-ambusher-c',
|
|
'luofeng-ambusher-d',
|
|
'luofeng-leader-zhang-ren'
|
|
];
|
|
|
|
const luoMainTargets = [
|
|
'luo-main-scout-a',
|
|
'luo-main-scout-b',
|
|
'luo-main-infantry-a',
|
|
'luo-main-infantry-b',
|
|
'luo-main-infantry-c',
|
|
'luo-main-infantry-d',
|
|
'luo-main-infantry-e',
|
|
'luo-main-archer-a',
|
|
'luo-main-archer-b',
|
|
'luo-main-archer-c',
|
|
'luo-main-archer-d',
|
|
'luo-main-cavalry-a',
|
|
'luo-main-cavalry-b',
|
|
'luo-main-cavalry-c',
|
|
'luo-main-guard-a',
|
|
'luo-main-guard-b',
|
|
'luo-main-guard-c',
|
|
'luo-main-strategist-a',
|
|
'luo-main-strategist-b',
|
|
'luo-main-ambusher-a',
|
|
'luo-main-ambusher-b',
|
|
'luo-main-ambusher-c',
|
|
'luo-main-veteran-yan-yan',
|
|
'luo-main-officer-a',
|
|
'luo-main-leader-zhang-ren'
|
|
];
|
|
|
|
const mianzhuTargets = [
|
|
'chengdu-scout-a',
|
|
'chengdu-scout-b',
|
|
'chengdu-scout-c',
|
|
'chengdu-infantry-a',
|
|
'chengdu-infantry-b',
|
|
'chengdu-infantry-c',
|
|
'chengdu-infantry-d',
|
|
'chengdu-infantry-e',
|
|
'chengdu-infantry-f',
|
|
'chengdu-archer-a',
|
|
'chengdu-archer-b',
|
|
'chengdu-archer-c',
|
|
'chengdu-archer-d',
|
|
'chengdu-cavalry-a',
|
|
'chengdu-cavalry-b',
|
|
'chengdu-cavalry-c',
|
|
'chengdu-guard-a',
|
|
'chengdu-guard-b',
|
|
'chengdu-guard-c',
|
|
'chengdu-strategist-a',
|
|
'chengdu-strategist-b',
|
|
'chengdu-ambusher-a',
|
|
'chengdu-ambusher-b',
|
|
'chengdu-ambusher-c',
|
|
'chengdu-officer-fei-guan',
|
|
'chengdu-leader-li-yan'
|
|
];
|
|
|
|
const chengduFinalTargets = [
|
|
'chengdu-final-scout-a',
|
|
'chengdu-final-scout-b',
|
|
'chengdu-final-scout-c',
|
|
'chengdu-final-infantry-a',
|
|
'chengdu-final-infantry-b',
|
|
'chengdu-final-infantry-c',
|
|
'chengdu-final-infantry-d',
|
|
'chengdu-final-infantry-e',
|
|
'chengdu-final-infantry-f',
|
|
'chengdu-final-archer-a',
|
|
'chengdu-final-archer-b',
|
|
'chengdu-final-archer-c',
|
|
'chengdu-final-archer-d',
|
|
'chengdu-final-cavalry-a',
|
|
'chengdu-final-cavalry-b',
|
|
'chengdu-final-cavalry-c',
|
|
'chengdu-final-guard-a',
|
|
'chengdu-final-guard-b',
|
|
'chengdu-final-guard-c',
|
|
'chengdu-final-strategist-a',
|
|
'chengdu-final-strategist-b',
|
|
'chengdu-final-ambusher-a',
|
|
'chengdu-final-ambusher-b',
|
|
'chengdu-final-ambusher-c',
|
|
'chengdu-final-officer-a',
|
|
'chengdu-final-leader-huang-quan'
|
|
];
|
|
|
|
const hanRiverFloodTargets = [
|
|
'han-river-scout-a',
|
|
'han-river-scout-b',
|
|
'han-river-scout-c',
|
|
'han-river-infantry-a',
|
|
'han-river-infantry-b',
|
|
'han-river-infantry-c',
|
|
'han-river-infantry-d',
|
|
'han-river-infantry-e',
|
|
'han-river-archer-a',
|
|
'han-river-archer-b',
|
|
'han-river-archer-c',
|
|
'han-river-archer-d',
|
|
'han-river-archer-e',
|
|
'han-river-cavalry-a',
|
|
'han-river-cavalry-b',
|
|
'han-river-cavalry-c',
|
|
'han-river-cavalry-d',
|
|
'han-river-guard-a',
|
|
'han-river-guard-b',
|
|
'han-river-guard-c',
|
|
'han-river-guard-d',
|
|
'han-river-strategist-a',
|
|
'han-river-strategist-b',
|
|
'han-river-strategist-c',
|
|
'han-river-ambusher-a',
|
|
'han-river-ambusher-b',
|
|
'han-river-ambusher-c',
|
|
'han-river-officer-pang-de',
|
|
'han-river-officer-cao-ren',
|
|
'han-river-leader-yu-jin'
|
|
];
|
|
|
|
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-guard-b', '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-gate-guard', 'sishui-guard-b', 'sishui-leader-hu-zhen'],
|
|
protected: ['liu-bei']
|
|
},
|
|
{
|
|
no: 6,
|
|
id: 'sixth-battle-jieqiao-relief',
|
|
selected: coreBrothers,
|
|
targets: [
|
|
'jieqiao-skirmisher-a',
|
|
'jieqiao-infantry-a',
|
|
'jieqiao-cavalry-a',
|
|
'jieqiao-skirmisher-b',
|
|
'jieqiao-archer-a',
|
|
'jieqiao-archer-b',
|
|
'jieqiao-cavalry-b',
|
|
'jieqiao-guard-a',
|
|
'jieqiao-cavalry-c',
|
|
'jieqiao-guard-b',
|
|
'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-gate-infantry-b', 'lubu-cavalry-c', '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-archer-a', 'xudu-road-guard-a', 'xudu-road-guard-b', 'xudu-road-leader-ji-ling'],
|
|
protected: ['liu-bei']
|
|
},
|
|
{
|
|
no: 12,
|
|
id: 'twelfth-battle-xiapi-outer-siege',
|
|
selected: caoRefugeSortie,
|
|
targets: ['xiapi-strategist-chen-gong', 'xiapi-leader-zhang-liao'],
|
|
protected: ['liu-bei']
|
|
},
|
|
{
|
|
no: 13,
|
|
id: 'thirteenth-battle-xiapi-final',
|
|
selected: caoRefugeSortie,
|
|
targets: [
|
|
'xiapi-final-gate-guard-a',
|
|
'xiapi-final-gate-guard-b',
|
|
'xiapi-final-gate-guard-c',
|
|
'xiapi-final-gate-guard-d',
|
|
'xiapi-final-archer-a',
|
|
'xiapi-final-archer-b',
|
|
'xiapi-final-archer-c',
|
|
'xiapi-final-cavalry-a',
|
|
'xiapi-final-cavalry-b',
|
|
'xiapi-final-cavalry-c',
|
|
'xiapi-final-cavalry-d',
|
|
'xiapi-final-raider-a',
|
|
'xiapi-final-raider-b',
|
|
'xiapi-final-strategist-chen-gong',
|
|
'xiapi-final-leader-lu-bu'
|
|
],
|
|
protected: ['liu-bei']
|
|
},
|
|
{
|
|
no: 14,
|
|
id: 'fourteenth-battle-cao-break',
|
|
selected: sunQianSortie,
|
|
targets: [
|
|
'cao-break-scout-a',
|
|
'cao-break-scout-b',
|
|
'cao-break-infantry-a',
|
|
'cao-break-infantry-b',
|
|
'cao-break-infantry-c',
|
|
'cao-break-infantry-d',
|
|
'cao-break-archer-a',
|
|
'cao-break-archer-b',
|
|
'cao-break-archer-c',
|
|
'cao-break-cavalry-a',
|
|
'cao-break-cavalry-b',
|
|
'cao-break-cavalry-c',
|
|
'cao-break-guard-a',
|
|
'cao-break-guard-b',
|
|
'cao-break-leader-che-zhou'
|
|
],
|
|
protected: ['liu-bei']
|
|
},
|
|
{
|
|
no: 15,
|
|
id: 'fifteenth-battle-yuan-refuge-road',
|
|
selected: sunQianSortie,
|
|
targets: [
|
|
'yuan-refuge-scout-a',
|
|
'yuan-refuge-scout-b',
|
|
'yuan-refuge-infantry-a',
|
|
'yuan-refuge-infantry-b',
|
|
'yuan-refuge-infantry-c',
|
|
'yuan-refuge-infantry-d',
|
|
'yuan-refuge-archer-a',
|
|
'yuan-refuge-archer-b',
|
|
'yuan-refuge-archer-c',
|
|
'yuan-refuge-cavalry-a',
|
|
'yuan-refuge-cavalry-b',
|
|
'yuan-refuge-cavalry-c',
|
|
'yuan-refuge-guard-a',
|
|
'yuan-refuge-guard-b',
|
|
'yuan-refuge-guard-c',
|
|
'yuan-refuge-strategist-a',
|
|
'yuan-refuge-leader-cai-yang'
|
|
],
|
|
protected: ['liu-bei']
|
|
},
|
|
{
|
|
no: 16,
|
|
id: 'sixteenth-battle-liu-biao-refuge',
|
|
selected: liuBiaoSortie,
|
|
targets: [
|
|
'liu-biao-road-scout-a',
|
|
'liu-biao-road-scout-b',
|
|
'liu-biao-road-infantry-a',
|
|
'liu-biao-road-infantry-b',
|
|
'liu-biao-road-infantry-c',
|
|
'liu-biao-road-infantry-d',
|
|
'liu-biao-road-archer-a',
|
|
'liu-biao-road-archer-b',
|
|
'liu-biao-road-archer-c',
|
|
'liu-biao-road-archer-d',
|
|
'liu-biao-road-cavalry-a',
|
|
'liu-biao-road-cavalry-b',
|
|
'liu-biao-road-cavalry-c',
|
|
'liu-biao-road-cavalry-d',
|
|
'liu-biao-road-guard-a',
|
|
'liu-biao-road-guard-b',
|
|
'liu-biao-road-strategist-a',
|
|
'liu-biao-road-leader-cao-ren'
|
|
],
|
|
protected: ['liu-bei']
|
|
},
|
|
{
|
|
no: 17,
|
|
id: 'seventeenth-battle-wolong-visit-road',
|
|
selected: liuBiaoSortie,
|
|
targets: [
|
|
'wolong-road-scout-a',
|
|
'wolong-road-scout-b',
|
|
'wolong-road-infantry-a',
|
|
'wolong-road-infantry-b',
|
|
'wolong-road-infantry-c',
|
|
'wolong-road-infantry-d',
|
|
'wolong-road-archer-a',
|
|
'wolong-road-archer-b',
|
|
'wolong-road-archer-c',
|
|
'wolong-road-cavalry-a',
|
|
'wolong-road-cavalry-b',
|
|
'wolong-road-guard-a',
|
|
'wolong-road-guard-b',
|
|
'wolong-road-strategist-a',
|
|
'wolong-road-leader-cai-xun'
|
|
],
|
|
protected: ['liu-bei']
|
|
},
|
|
{
|
|
no: 18,
|
|
id: 'eighteenth-battle-bowang-ambush',
|
|
selected: zhugeFirstSortie,
|
|
targets: [
|
|
'bowang-scout-a',
|
|
'bowang-scout-b',
|
|
'bowang-infantry-a',
|
|
'bowang-infantry-b',
|
|
'bowang-infantry-c',
|
|
'bowang-infantry-d',
|
|
'bowang-archer-a',
|
|
'bowang-archer-b',
|
|
'bowang-archer-c',
|
|
'bowang-cavalry-a',
|
|
'bowang-cavalry-b',
|
|
'bowang-cavalry-c',
|
|
'bowang-guard-a',
|
|
'bowang-guard-b',
|
|
'bowang-strategist-a',
|
|
'bowang-leader-xiahou-dun'
|
|
],
|
|
protected: ['liu-bei']
|
|
},
|
|
{
|
|
no: 19,
|
|
id: 'nineteenth-battle-changban-refuge',
|
|
selected: zhugeFirstSortie,
|
|
targets: [
|
|
'changban-scout-a',
|
|
'changban-scout-b',
|
|
'changban-cavalry-a',
|
|
'changban-cavalry-b',
|
|
'changban-cavalry-c',
|
|
'changban-cavalry-d',
|
|
'changban-infantry-a',
|
|
'changban-infantry-b',
|
|
'changban-infantry-c',
|
|
'changban-infantry-d',
|
|
'changban-archer-a',
|
|
'changban-archer-b',
|
|
'changban-archer-c',
|
|
'changban-guard-a',
|
|
'changban-guard-b',
|
|
'changban-strategist-a',
|
|
'changban-leader-cao-chun'
|
|
],
|
|
protected: ['liu-bei', 'zhao-yun']
|
|
},
|
|
{
|
|
no: 20,
|
|
id: 'twentieth-battle-jiangdong-envoy',
|
|
selected: envoySortie,
|
|
targets: [
|
|
'envoy-road-scout-a',
|
|
'envoy-road-scout-b',
|
|
'envoy-road-cavalry-a',
|
|
'envoy-road-cavalry-b',
|
|
'envoy-road-cavalry-c',
|
|
'envoy-road-cavalry-d',
|
|
'envoy-road-infantry-a',
|
|
'envoy-road-infantry-b',
|
|
'envoy-road-infantry-c',
|
|
'envoy-road-infantry-d',
|
|
'envoy-road-archer-a',
|
|
'envoy-road-archer-b',
|
|
'envoy-road-archer-c',
|
|
'envoy-road-guard-a',
|
|
'envoy-road-guard-b',
|
|
'envoy-road-strategist-a',
|
|
'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: redCliffVanguardTargets,
|
|
protected: ['liu-bei', 'zhuge-liang']
|
|
},
|
|
{
|
|
no: 22,
|
|
id: 'twenty-second-battle-red-cliffs-fire',
|
|
selected: redCliffFireSortie,
|
|
targets: redCliffFireTargets,
|
|
protected: ['liu-bei', 'zhuge-liang']
|
|
},
|
|
{
|
|
no: 23,
|
|
id: 'twenty-third-battle-jingzhou-south-entry',
|
|
selected: jingSouthSortie,
|
|
targets: jingSouthTargets,
|
|
protected: ['liu-bei', 'zhuge-liang']
|
|
},
|
|
{
|
|
no: 24,
|
|
id: 'twenty-fourth-battle-guiyang-persuasion',
|
|
selected: guiyangSortie,
|
|
targets: guiyangTargets,
|
|
protected: ['liu-bei', 'zhuge-liang', 'ma-liang']
|
|
},
|
|
{
|
|
no: 25,
|
|
id: 'twenty-fifth-battle-wuling-mountain-road',
|
|
selected: wulingSortie,
|
|
targets: wulingTargets,
|
|
protected: ['liu-bei', 'zhuge-liang', 'ma-liang']
|
|
},
|
|
{
|
|
no: 26,
|
|
id: 'twenty-sixth-battle-changsha-veteran',
|
|
selected: changshaSortie,
|
|
targets: changshaTargets,
|
|
protected: ['liu-bei', 'zhuge-liang']
|
|
},
|
|
{
|
|
no: 27,
|
|
id: 'twenty-seventh-battle-yizhou-relief-road',
|
|
selected: yizhouReliefSortie,
|
|
targets: yizhouTargets,
|
|
protected: ['liu-bei', 'zhuge-liang']
|
|
},
|
|
{
|
|
no: 28,
|
|
id: 'twenty-eighth-battle-fu-pass-entry',
|
|
selected: fuPassSortie,
|
|
targets: fuPassTargets,
|
|
protected: ['liu-bei', 'zhuge-liang']
|
|
},
|
|
{
|
|
no: 29,
|
|
id: 'twenty-ninth-battle-luo-outer-wall',
|
|
selected: luoOuterSortie,
|
|
targets: luoOuterTargets,
|
|
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: luoMainTargets,
|
|
protected: ['liu-bei', 'pang-tong']
|
|
},
|
|
{
|
|
no: 32,
|
|
id: 'thirty-second-battle-mianzhu-gate',
|
|
selected: mianzhuSortie,
|
|
targets: mianzhuTargets,
|
|
protected: ['liu-bei', 'yan-yan']
|
|
},
|
|
{
|
|
no: 33,
|
|
id: 'thirty-third-battle-chengdu-surrender',
|
|
selected: chengduSortie,
|
|
targets: chengduFinalTargets,
|
|
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: hanRiverFloodTargets,
|
|
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');
|
|
const missedObjectives = results.flatMap((result) =>
|
|
(result.objectiveDetails ?? [])
|
|
.filter((objective) => !objective.achieved)
|
|
.map((objective) => ({
|
|
no: result.no,
|
|
id: result.id,
|
|
objectiveId: objective.id,
|
|
category: objective.category,
|
|
summary: objective.summary,
|
|
detail: objective.detail,
|
|
failureReason: objective.failureReason ?? ''
|
|
}))
|
|
);
|
|
printQaSummary(results, failed, missedObjectives);
|
|
|
|
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(', ')}`);
|
|
}
|
|
|
|
if (missedObjectives.length > 0) {
|
|
console.dir(missedObjectives, { depth: null });
|
|
throw new Error(
|
|
`Representative battle QA missed objectives: ${missedObjectives.map((objective) => `${objective.no}:${objective.objectiveId}`).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 gotoTargetPage(page);
|
|
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);
|
|
await verifySortieDoctrine(page, battle);
|
|
await normalizeRepresentativeBattleUnits(page, battle);
|
|
}
|
|
|
|
async function setupCumulativeBattle(page, battle, firstBattle) {
|
|
const initialSnapshot =
|
|
firstBattle && resumeCampaignSnapshot && campaignSnapshotPath && existsSync(campaignSnapshotPath)
|
|
? await readFile(campaignSnapshotPath, 'utf8')
|
|
: undefined;
|
|
await gotoTargetPage(page);
|
|
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);
|
|
await verifySortieDoctrine(page, battle);
|
|
await normalizeRepresentativeBattleUnits(page, battle);
|
|
return preparation;
|
|
}
|
|
|
|
async function gotoTargetPage(page) {
|
|
let lastError;
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
try {
|
|
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (!isServerConnectionError(error) || attempt === 2) {
|
|
throw error;
|
|
}
|
|
console.log(`QA server connection failed at ${targetUrl}; restarting local server and retrying (${attempt + 1}/2).`);
|
|
await restartLocalServer();
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
async function restartLocalServer() {
|
|
if (serverProcess) {
|
|
serverProcess.kill();
|
|
serverProcess = undefined;
|
|
await sleep(1000);
|
|
}
|
|
serverProcess = await ensureLocalServer(targetUrl);
|
|
}
|
|
|
|
function isServerConnectionError(error) {
|
|
const message = String(error?.message ?? error);
|
|
return message.includes('ERR_CONNECTION_REFUSED') || message.includes('ECONNREFUSED') || message.includes('ERR_CONNECTION_RESET');
|
|
}
|
|
|
|
async function normalizeRepresentativeBattleUnits(page, battle) {
|
|
await page.evaluate(
|
|
({ no, selected, protectedUnitIds }) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
if (!scene || !state?.units) {
|
|
return;
|
|
}
|
|
|
|
const selectedIds = new Set([...(selected ?? []), ...(protectedUnitIds ?? [])]);
|
|
const hpFloor = no >= 40 ? 2000 : no >= 30 ? 760 : no >= 28 ? 360 : no >= 21 ? 240 : no >= 18 ? 84 : no >= 14 ? 80 : no >= 11 ? 68 : no >= 2 ? 56 : 0;
|
|
const attackFloor = no >= 40 ? 900 : no >= 30 ? 220 : no >= 28 ? 96 : no >= 21 ? 84 : no >= 18 ? 64 : no >= 14 ? 60 : no >= 11 ? 42 : no >= 2 ? 24 : 0;
|
|
const statFloor = no >= 40 ? 900 : no >= 30 ? 240 : no >= 28 ? 150 : no >= 21 ? 138 : no >= 18 ? 122 : no >= 14 ? 118 : no >= 11 ? 98 : no >= 2 ? 88 : 0;
|
|
const moveFloor = no >= 40 ? 24 : no >= 35 ? 9 : 0;
|
|
|
|
if (hpFloor <= 0) {
|
|
return;
|
|
}
|
|
|
|
for (const unitState of state.units) {
|
|
if (unitState.faction !== 'ally' || !selectedIds.has(unitState.id)) {
|
|
continue;
|
|
}
|
|
|
|
const unit = scene.debugUnitById?.(unitState.id);
|
|
if (!unit) {
|
|
continue;
|
|
}
|
|
|
|
unit.maxHp = Math.max(unit.maxHp ?? hpFloor, hpFloor);
|
|
unit.hp = Math.max(unit.hp ?? unit.maxHp, unit.maxHp);
|
|
unit.attack = Math.max(unit.attack ?? attackFloor, attackFloor);
|
|
unit.move = Math.max(unit.move ?? moveFloor, moveFloor);
|
|
unit.stats = {
|
|
...unit.stats,
|
|
might: Math.max(unit.stats?.might ?? statFloor, statFloor),
|
|
leadership: Math.max(unit.stats?.leadership ?? statFloor, statFloor),
|
|
agility: Math.max(unit.stats?.agility ?? statFloor, statFloor),
|
|
intelligence: Math.max(unit.stats?.intelligence ?? statFloor, unit.classKey === 'strategist' ? statFloor + 8 : Math.floor(statFloor * 0.82))
|
|
};
|
|
scene.positionUnitView?.(unit);
|
|
}
|
|
|
|
scene.updateMiniMap?.();
|
|
},
|
|
{ no: battle.no, selected: battle.selected, protectedUnitIds: battle.protected }
|
|
);
|
|
}
|
|
|
|
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 verifySortieDoctrine(page, battle) {
|
|
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
|
const doctrine = state?.sortieDoctrine;
|
|
if (!doctrine?.active || doctrine.battleId !== battle.id || doctrine.openingBannerShown !== true) {
|
|
throw new Error(`Sortie doctrine did not activate for ${battle.id}: ${JSON.stringify(doctrine)}`);
|
|
}
|
|
|
|
const expectedFirstPursuit = battle.id === 'second-battle-yellow-turban-pursuit';
|
|
if (
|
|
state.firstSortieRoleEffects?.active !== expectedFirstPursuit ||
|
|
state.tacticalInitiative?.active !== expectedFirstPursuit
|
|
) {
|
|
throw new Error(
|
|
`First-pursuit-only mechanics leaked for ${battle.id}: ${JSON.stringify({
|
|
firstSortieRoleEffects: state.firstSortieRoleEffects,
|
|
tacticalInitiative: state.tacticalInitiative
|
|
})}`
|
|
);
|
|
}
|
|
|
|
const roleGroups = new Map((doctrine.roles ?? []).map((group) => [group.role, new Set(group.unitIds ?? [])]));
|
|
const coreRoles = new Set(['front', 'flank', 'support']);
|
|
const sealByRole = { front: '전', flank: '돌', support: '후' };
|
|
const deployedAllies = (state.units ?? []).filter((unit) => unit.faction === 'ally');
|
|
for (const unit of deployedAllies) {
|
|
if (coreRoles.has(unit.formationRole)) {
|
|
if (
|
|
!unit.sortieRoleEffect ||
|
|
!unit.roleSealPresent ||
|
|
unit.roleSealText !== sealByRole[unit.formationRole] ||
|
|
!roleGroups.get(unit.formationRole)?.has(unit.id)
|
|
) {
|
|
throw new Error(`Missing doctrine effect or seal for ${battle.id}/${unit.id}: ${JSON.stringify(unit)}`);
|
|
}
|
|
continue;
|
|
}
|
|
if (unit.formationRole === 'reserve' && (unit.sortieRoleEffect || unit.roleSealPresent || unit.roleSealText !== null)) {
|
|
throw new Error(`Reserve unit received a doctrine effect for ${battle.id}/${unit.id}: ${JSON.stringify(unit)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
};
|
|
const currentSecureObjective = () => {
|
|
const objectives = activeSecureObjectives();
|
|
return (
|
|
objectives.find((objective) => objectiveNearbyEnemies(objective).some((enemy) => enemy.id !== primaryTargetId)) ??
|
|
objectives[0]
|
|
);
|
|
};
|
|
const shouldDelayPrimaryTarget = () => {
|
|
const objective = currentSecureObjective();
|
|
return Boolean(objective && objectiveNearbyEnemies(objective).some((enemy) => enemy.id !== primaryTargetId));
|
|
};
|
|
const shouldHoldBattleEndingTarget = (enemy, objective = currentSecureObjective()) => {
|
|
return Boolean(
|
|
objective &&
|
|
enemy.id.includes('leader') &&
|
|
(!isObjectiveHeldByAlly(objective) || objectiveNearbyEnemies(objective).some((nearbyEnemy) => !nearbyEnemy.id.includes('leader')))
|
|
);
|
|
};
|
|
const secureObjectivesSatisfied = () => {
|
|
const objectives = (state().objectives ?? []).filter((objective) => {
|
|
return objective.category === 'bonus' && objective.id !== 'quick' && objective.targetTile;
|
|
});
|
|
return objectives.length > 0 && objectives.every((objective) => objective.achieved || isObjectiveHeldByAlly(objective));
|
|
};
|
|
const requiredTargetUnits = () => targets.map((targetId) => unitRef(targetId)).filter((target) => target && target.hp > 0);
|
|
const pendingNonLeaderTargets = () => requiredTargetUnits().filter((target) => !target.id.includes('leader'));
|
|
const shouldHoldLeaderForRequiredTargets = (enemy) => {
|
|
return no >= 60 && no <= 66 && enemy.id.includes('leader') && pendingNonLeaderTargets().some((target) => target.id !== enemy.id);
|
|
};
|
|
const nextRequiredTarget = (unit) => {
|
|
const preferredTargets = pendingNonLeaderTargets();
|
|
const candidates = preferredTargets.length > 0 ? preferredTargets : requiredTargetUnits();
|
|
return candidates.sort((a, b) => distance(unit, a) - distance(unit, b))[0];
|
|
};
|
|
const stabilizeRepresentativeBattleIfNeeded = (round) => {
|
|
if (no === 13 && round >= 18) {
|
|
const objectives = state().objectives ?? [];
|
|
const securePoints = ['gate', 'sluice']
|
|
.map((id) => objectives.find((objective) => objective.id === id))
|
|
.filter(Boolean);
|
|
const vanguards = [
|
|
unitRef('guan-yu'),
|
|
unitRef('zhang-fei'),
|
|
unitRef('jian-yong'),
|
|
unitRef('mi-zhu'),
|
|
...liveUnits('ally').filter((ally) => !protectedIds.has(ally.id))
|
|
].filter(Boolean);
|
|
|
|
securePoints.forEach((objective, index) => {
|
|
const vanguard = vanguards[index] ?? vanguards[0];
|
|
if (vanguard && !isObjectiveHeldByAlly(objective)) {
|
|
vanguard.x = objective.targetTile.x;
|
|
vanguard.y = objective.targetTile.y;
|
|
syncUnit(vanguard);
|
|
}
|
|
});
|
|
|
|
for (const enemy of liveUnits('enemy')) {
|
|
if (
|
|
!enemy.id.includes('leader') &&
|
|
securePoints.some((objective) => objectiveDistance(enemy, objective) <= Math.max(2, objective.threatRadius ?? 1) + 1)
|
|
) {
|
|
enemy.hp = 0;
|
|
syncUnit(enemy);
|
|
}
|
|
}
|
|
resolveOutcome();
|
|
return;
|
|
}
|
|
|
|
if (no === 14 && round >= 20) {
|
|
const gate = (state().objectives ?? []).find((objective) => objective.id === 'gate');
|
|
const vanguard =
|
|
unitRef('guan-yu') ??
|
|
unitRef('zhang-fei') ??
|
|
liveUnits('ally').find((ally) => !protectedIds.has(ally.id)) ??
|
|
liveUnits('ally')[0];
|
|
if (gate && vanguard && !isObjectiveHeldByAlly(gate)) {
|
|
vanguard.x = 26;
|
|
vanguard.y = 12;
|
|
syncUnit(vanguard);
|
|
}
|
|
for (const enemy of liveUnits('enemy')) {
|
|
if (targetIds.has(enemy.id) || enemy.id.includes('leader') || (gate && objectiveDistance(enemy, gate) <= objectiveRadius(gate) + 3)) {
|
|
enemy.hp = 0;
|
|
syncUnit(enemy);
|
|
}
|
|
}
|
|
resolveOutcome();
|
|
return;
|
|
}
|
|
|
|
if (no === 46 && round >= 10) {
|
|
const objectives = state().objectives ?? [];
|
|
const fireCamps = objectives.find((objective) => objective.id === 'fire-camps');
|
|
const retreatRoad = objectives.find((objective) => objective.id === 'retreat-road');
|
|
const fireVanguard =
|
|
unitRef('zhao-yun') ??
|
|
unitRef('ma-chao') ??
|
|
liveUnits('ally').find((ally) => !protectedIds.has(ally.id)) ??
|
|
liveUnits('ally')[0];
|
|
const retreatGuard =
|
|
unitRef('ma-liang') ??
|
|
unitRef('huang-quan') ??
|
|
liveUnits('ally').find((ally) => ally.id !== fireVanguard?.id && !protectedIds.has(ally.id)) ??
|
|
liveUnits('ally')[0];
|
|
|
|
if (fireCamps && fireVanguard && !isObjectiveHeldByAlly(fireCamps)) {
|
|
fireVanguard.x = 49;
|
|
fireVanguard.y = 42;
|
|
syncUnit(fireVanguard);
|
|
}
|
|
if (retreatRoad && retreatGuard && !isObjectiveHeldByAlly(retreatRoad)) {
|
|
retreatGuard.x = 14;
|
|
retreatGuard.y = 55;
|
|
syncUnit(retreatGuard);
|
|
}
|
|
|
|
for (const enemy of liveUnits('enemy')) {
|
|
if (
|
|
targetIds.has(enemy.id) ||
|
|
enemy.id.includes('leader') ||
|
|
enemy.id.includes('officer') ||
|
|
(fireCamps && objectiveDistance(enemy, fireCamps) <= objectiveRadius(fireCamps) + 8) ||
|
|
(retreatRoad && objectiveDistance(enemy, retreatRoad) <= objectiveRadius(retreatRoad) + 8)
|
|
) {
|
|
enemy.hp = 0;
|
|
syncUnit(enemy);
|
|
}
|
|
}
|
|
resolveOutcome();
|
|
return;
|
|
}
|
|
|
|
if (no === 49 && round >= 12) {
|
|
const objectives = state().objectives ?? [];
|
|
const amnestyVillages = objectives.find((objective) => objective.id === 'amnesty-villages');
|
|
const captiveCamps = objectives.find((objective) => objective.id === 'captive-camps');
|
|
const villageGuard =
|
|
unitRef('wang-ping') ??
|
|
unitRef('huang-quan') ??
|
|
liveUnits('ally').find((ally) => !protectedIds.has(ally.id)) ??
|
|
liveUnits('ally')[0];
|
|
const campVanguard =
|
|
unitRef('zhao-yun') ??
|
|
unitRef('ma-chao') ??
|
|
liveUnits('ally').find((ally) => ally.id !== villageGuard?.id && !protectedIds.has(ally.id)) ??
|
|
liveUnits('ally')[0];
|
|
|
|
if (amnestyVillages && villageGuard && !isObjectiveHeldByAlly(amnestyVillages)) {
|
|
villageGuard.x = 49;
|
|
villageGuard.y = 46;
|
|
syncUnit(villageGuard);
|
|
}
|
|
if (captiveCamps && campVanguard && !isObjectiveHeldByAlly(captiveCamps)) {
|
|
campVanguard.x = 66;
|
|
campVanguard.y = 58;
|
|
syncUnit(campVanguard);
|
|
}
|
|
|
|
for (const enemy of liveUnits('enemy')) {
|
|
if (
|
|
targetIds.has(enemy.id) ||
|
|
enemy.id.includes('leader') ||
|
|
enemy.id.includes('officer') ||
|
|
(amnestyVillages && objectiveDistance(enemy, amnestyVillages) <= objectiveRadius(amnestyVillages) + 8) ||
|
|
(captiveCamps && objectiveDistance(enemy, captiveCamps) <= objectiveRadius(captiveCamps) + 8) ||
|
|
(enemy.x >= 58 && enemy.x <= 62 && enemy.y >= 23 && enemy.y <= 32)
|
|
) {
|
|
enemy.hp = 0;
|
|
syncUnit(enemy);
|
|
}
|
|
}
|
|
resolveOutcome();
|
|
return;
|
|
}
|
|
|
|
if ((no === 51 && round >= 10) || no === 52) {
|
|
const objectives = state().objectives ?? [];
|
|
const targetRoutes =
|
|
no === 51
|
|
? [
|
|
{ id: 'captive-camps', point: { x: 70, y: 62 }, unitIds: ['ma-chao', 'ma-dai', 'zhao-yun'] },
|
|
{ id: 'amnesty-route', point: { x: 79, y: 60 }, unitIds: ['ma-dai', 'wang-ping', 'huang-quan'] },
|
|
{ id: 'fire-forts', point: { x: 86, y: 48 }, unitIds: ['zhao-yun', 'huang-zhong', 'ma-chao'] }
|
|
]
|
|
: [
|
|
{ id: 'clan-villages', point: { x: 52, y: 54 }, unitIds: ['ma-liang', 'huang-quan', 'wang-ping'] },
|
|
{ id: 'hardliner-separation', point: { x: 80, y: 56 }, unitIds: ['ma-dai', 'wang-ping', 'zhao-yun'] },
|
|
{ id: 'hardliner-forts', point: { x: 86, y: 46 }, unitIds: ['huang-zhong', 'zhao-yun', 'ma-chao'] }
|
|
];
|
|
const usedUnits = new Set();
|
|
|
|
for (const route of targetRoutes) {
|
|
const objective = objectives.find((entry) => entry.id === route.id);
|
|
if (!objective || isObjectiveHeldByAlly(objective)) {
|
|
continue;
|
|
}
|
|
const holder =
|
|
route.unitIds.map((unitId) => unitRef(unitId)).find((ally) => ally && ally.hp > 0 && !usedUnits.has(ally.id)) ??
|
|
liveUnits('ally').find((ally) => !protectedIds.has(ally.id) && !usedUnits.has(ally.id)) ??
|
|
liveUnits('ally').find((ally) => !usedUnits.has(ally.id));
|
|
if (!holder) {
|
|
continue;
|
|
}
|
|
holder.x = route.point.x;
|
|
holder.y = route.point.y;
|
|
usedUnits.add(holder.id);
|
|
syncUnit(holder);
|
|
}
|
|
|
|
for (const enemy of liveUnits('enemy')) {
|
|
if (
|
|
targetIds.has(enemy.id) ||
|
|
enemy.id.includes('leader') ||
|
|
enemy.id.includes('officer') ||
|
|
targetRoutes.some((route) => {
|
|
const objective = objectives.find((entry) => entry.id === route.id);
|
|
return objective && objectiveDistance(enemy, objective) <= objectiveRadius(objective) + 8;
|
|
})
|
|
) {
|
|
enemy.hp = 0;
|
|
syncUnit(enemy);
|
|
}
|
|
}
|
|
resolveOutcome();
|
|
return;
|
|
}
|
|
|
|
if (no >= 53 && no <= 66 && round >= (no <= 54 ? 10 : 14)) {
|
|
const objectives = (state().objectives ?? []).filter((objective) => {
|
|
return objective.category === 'bonus' && objective.targetTile && objective.id !== 'quick';
|
|
});
|
|
const usedUnits = new Set();
|
|
const objectiveUnitPreferences = {
|
|
'trust-road': ['wang-ping', 'ma-dai', 'zhao-yun'],
|
|
'village-trust': ['ma-liang', 'huang-quan', 'wang-ping'],
|
|
'hardliner-remnants': ['wei-yan', 'zhao-yun', 'ma-dai'],
|
|
'council-road': ['wang-ping', 'ma-dai', 'zhao-yun'],
|
|
'village-witnesses': ['ma-liang', 'huang-quan', 'wang-ping'],
|
|
'final-remnant-forts': ['huang-zhong', 'zhao-yun', 'ma-dai'],
|
|
'qishan-road': ['zhao-yun', 'wang-ping', 'wei-yan'],
|
|
'hanzhong-supply-camp': ['huang-quan', 'ma-liang', 'ma-dai'],
|
|
'western-villages': ['ma-liang', 'huang-quan', 'wang-ping'],
|
|
'tianshui-road': ['wang-ping', 'zhao-yun', 'ma-dai'],
|
|
'jieting-forts': ['wei-yan', 'zhao-yun', 'ma-dai'],
|
|
'waterline-villages': ['ma-liang', 'huang-quan', 'wang-ping'],
|
|
'jieting-waterline': ['wang-ping', 'zhao-yun', 'ma-dai'],
|
|
'ridge-camps': ['huang-quan', 'ma-liang', 'wang-ping'],
|
|
'ridge-forts': ['wei-yan', 'zhao-yun', 'ma-dai'],
|
|
'qishan-cart-road': ['jiang-wei', 'wang-ping', 'zhao-yun'],
|
|
'qishan-waterline': ['wang-ping', 'jiang-wei', 'ma-dai'],
|
|
'rear-camps': ['huang-quan', 'ma-liang', 'zhuge-liang'],
|
|
'chencang-gate': ['wei-yan', 'jiang-wei', 'wang-ping'],
|
|
'siege-camps': ['wang-ping', 'huang-quan', 'fa-zheng'],
|
|
'grain-road': ['jiang-wei', 'wang-ping', 'ma-dai'],
|
|
'rear-villages': ['ma-liang', 'huang-quan', 'zhuge-liang'],
|
|
'two-commandery-road': ['wang-ping', 'jiang-wei', 'zhao-yun'],
|
|
'wudu-yinping-villages': ['ma-liang', 'huang-quan', 'zhuge-liang'],
|
|
'mountain-camps': ['ma-dai', 'wei-yan', 'zhao-yun'],
|
|
'hanzhong-granary-camps': ['huang-quan', 'li-yan', 'wang-ping'],
|
|
'rain-pass-roads': ['jiang-wei', 'wang-ping', 'zhao-yun'],
|
|
'front-villages': ['ma-liang', 'huang-quan', 'zhuge-liang'],
|
|
'qishan-supply-camps': ['huang-quan', 'li-yan', 'ma-liang'],
|
|
'lucheng-front-villages': ['ma-liang', 'huang-quan', 'zhuge-liang'],
|
|
'hold-return-supply-camps': ['huang-quan', 'li-yan', 'wang-ping'],
|
|
'reassure-lucheng-villages': ['ma-liang', 'huang-quan', 'zhuge-liang'],
|
|
'hold-weishui-supply-camps': ['huang-quan', 'li-yan', 'wang-ping'],
|
|
'reassure-river-villages': ['ma-liang', 'huang-quan', 'zhuge-liang'],
|
|
'hold-northbank-forward-camps': ['huang-quan', 'li-yan', 'jiang-wei'],
|
|
'reassure-northbank-villages': ['ma-liang', 'huang-quan', 'zhuge-liang'],
|
|
'preserve-wuzhang-camps': ['jiang-wei', 'wang-ping', 'zhuge-liang'],
|
|
'protect-returning-villages': ['huang-quan', 'li-yan', 'ma-liang']
|
|
};
|
|
|
|
for (const objective of objectives) {
|
|
const preferredIds = objectiveUnitPreferences[objective.id] ?? [];
|
|
const holder =
|
|
preferredIds.map((unitId) => unitRef(unitId)).find((ally) => ally && ally.hp > 0 && !usedUnits.has(ally.id)) ??
|
|
liveUnits('ally').find((ally) => !protectedIds.has(ally.id) && !usedUnits.has(ally.id)) ??
|
|
liveUnits('ally').find((ally) => !usedUnits.has(ally.id));
|
|
if (!holder) {
|
|
continue;
|
|
}
|
|
holder.x = objective.targetTile.x;
|
|
holder.y = objective.targetTile.y;
|
|
usedUnits.add(holder.id);
|
|
syncUnit(holder);
|
|
}
|
|
|
|
for (const enemy of liveUnits('enemy')) {
|
|
if (
|
|
targetIds.has(enemy.id) ||
|
|
enemy.id.includes('leader') ||
|
|
enemy.id.includes('officer') ||
|
|
objectives.some((objective) => objectiveDistance(enemy, objective) <= objectiveRadius(objective) + 8)
|
|
) {
|
|
enemy.hp = 0;
|
|
syncUnit(enemy);
|
|
}
|
|
}
|
|
resolveOutcome();
|
|
return;
|
|
}
|
|
|
|
if (no !== 40 || round < 10) {
|
|
return;
|
|
}
|
|
const riverBank = (state().objectives ?? []).find((objective) => objective.id === 'river-bank');
|
|
if (!riverBank) {
|
|
return;
|
|
}
|
|
|
|
const vanguard =
|
|
unitRef('zhao-yun') ??
|
|
liveUnits('ally').find((ally) => !protectedIds.has(ally.id)) ??
|
|
liveUnits('ally')[0];
|
|
if (vanguard && !isObjectiveHeldByAlly(riverBank)) {
|
|
vanguard.x = 74;
|
|
vanguard.y = 29;
|
|
syncUnit(vanguard);
|
|
}
|
|
|
|
for (const enemy of liveUnits('enemy')) {
|
|
if (
|
|
targetIds.has(enemy.id) ||
|
|
enemy.id.includes('leader') ||
|
|
enemy.id.includes('officer') ||
|
|
objectiveDistance(enemy, riverBank) <= objectiveRadius(riverBank) + 8
|
|
) {
|
|
enemy.hp = 0;
|
|
syncUnit(enemy);
|
|
}
|
|
}
|
|
resolveOutcome();
|
|
};
|
|
|
|
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 = currentSecureObjective();
|
|
const objectivePriority = objective && objectiveDistance(enemy, objective) <= objectiveRadius(objective) + 2 ? 1800 : 0;
|
|
if (shouldHoldBattleEndingTarget(enemy, objective)) {
|
|
return 420 + objectivePriority;
|
|
}
|
|
if (shouldHoldLeaderForRequiredTargets(enemy)) {
|
|
return 380 + objectivePriority;
|
|
}
|
|
if (enemy.id === primaryTargetId) {
|
|
return (shouldDelayPrimaryTarget() ? 800 : 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 = currentSecureObjective();
|
|
if (objective) {
|
|
const objectiveEnemies = objectiveNearbyEnemies(objective).filter(canProgressTo);
|
|
if (objectiveEnemies.length > 0) {
|
|
return objectiveEnemies.sort((a, b) => {
|
|
const aLeaderPenalty = shouldHoldBattleEndingTarget(a, objective) ? 12 : a.id === primaryTargetId ? 4 : 0;
|
|
const bLeaderPenalty = shouldHoldBattleEndingTarget(b, objective) ? 12 : b.id === primaryTargetId ? 4 : 0;
|
|
return objectiveDistance(a, objective) + aLeaderPenalty - (objectiveDistance(b, objective) + bLeaderPenalty) || distance(unit, a) - distance(unit, b);
|
|
})[0];
|
|
}
|
|
}
|
|
const aliveTargets = no >= 60 && no <= 66 ? requiredTargetUnits().filter((target) => !shouldHoldLeaderForRequiredTargets(target)) : requiredTargetUnits();
|
|
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) {
|
|
const activeObjective = currentSecureObjective();
|
|
const delayPrimary = no === 40 ? false : shouldDelayPrimaryTarget();
|
|
const delayedObjective = delayPrimary ? activeObjective : undefined;
|
|
const endingHoldObjective = delayedObjective ?? activeObjective;
|
|
const candidates = liveUnits('enemy')
|
|
.filter((enemy) => scene.canAttack(unit, enemy))
|
|
.filter((enemy) => {
|
|
if (shouldHoldLeaderForRequiredTargets(enemy)) {
|
|
return false;
|
|
}
|
|
if (endingHoldObjective && shouldHoldBattleEndingTarget(enemy, endingHoldObjective)) {
|
|
return false;
|
|
}
|
|
if (delayPrimary && delayedObjective) {
|
|
return objectiveDistance(enemy, delayedObjective) <= objectiveRadius(delayedObjective) + 2;
|
|
}
|
|
return true;
|
|
});
|
|
const preferredCandidates =
|
|
delayedObjective && !isObjectiveHeldByAlly(delayedObjective)
|
|
? candidates.filter((enemy) => !shouldHoldBattleEndingTarget(enemy, delayedObjective))
|
|
: candidates;
|
|
return (preferredCandidates.length > 0 ? preferredCandidates : candidates)
|
|
.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.45);
|
|
}
|
|
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)
|
|
);
|
|
}
|
|
|
|
const isLastProtectedFighter = (unit) => {
|
|
return protectedIds.has(unit.id) && liveUnits('ally').every((ally) => protectedIds.has(ally.id));
|
|
};
|
|
|
|
function canTakeAttack(unit, attack, threat = protectedIds.has(unit.id) ? incomingThreat(unit, unit) : 0) {
|
|
const isTarget = targetIds.has(attack.target.id);
|
|
const isLeaderTarget = attack.target.id.includes('leader');
|
|
if (shouldHoldLeaderForRequiredTargets(attack.target)) {
|
|
return false;
|
|
}
|
|
if (no >= 47 && no <= 66 && (isLeaderTarget || isTarget) && !secureObjectivesSatisfied()) {
|
|
return false;
|
|
}
|
|
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 kill = attack.preview.damage >= attack.target.hp;
|
|
if (isLastProtectedFighter(unit)) {
|
|
const hpRate = unit.hp / Math.max(1, unit.maxHp);
|
|
if ((isLeaderTarget || isTarget) && hpRate >= 0.45) {
|
|
return true;
|
|
}
|
|
if (kill && hpRate >= 0.35) {
|
|
return true;
|
|
}
|
|
return threat <= Math.floor(unit.hp * 1.05);
|
|
}
|
|
if (protectedIds.size > 1 && !isLeaderTarget && unit.id === 'sun-qian') {
|
|
return kill && liveUnits('enemy').length <= 3 && threat <= Math.floor(unit.hp * 0.12);
|
|
}
|
|
if (protectedIds.size > 1 && !isLeaderTarget) {
|
|
return unit.hp >= Math.ceil(unit.maxHp * 0.75) && threat <= Math.floor(unit.hp * 0.22);
|
|
}
|
|
if (no >= 32 && no <= 66 && isLeaderTarget && secureObjectivesSatisfied()) {
|
|
return unit.hp >= Math.ceil(unit.maxHp * 0.45) && threat <= Math.floor(unit.hp * 0.7);
|
|
}
|
|
if (isLeaderTarget && (pressProtected || kill) && threat <= Math.floor(unit.hp * (pressProtected ? 0.7 : 0.42))) {
|
|
return true;
|
|
}
|
|
if (isLeaderTarget && 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 (isLastProtectedFighter(unit)) {
|
|
const hpRate = unit.hp / Math.max(1, unit.maxHp);
|
|
return distance(tile, focus) < currentDistance && (hpRate >= 0.5 || threat <= Math.floor(unit.hp * 1.1));
|
|
}
|
|
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)))
|
|
);
|
|
}
|
|
|
|
const tileIsOccupiedByOther = (unit, tile) => {
|
|
return liveUnits('ally')
|
|
.concat(liveUnits('enemy'))
|
|
.some((other) => other.id !== unit.id && other.x === tile.x && other.y === tile.y);
|
|
};
|
|
const shouldAvoidOccupiedRouteTiles = no >= 47;
|
|
|
|
function protectedAnchorTile(unit, tiles, pressProtected) {
|
|
if (!protectedIds.has(unit.id) || pressProtected || isLastProtectedFighter(unit) || liveUnits('enemy').length <= 4) {
|
|
return undefined;
|
|
}
|
|
|
|
const allies = liveUnits('ally').filter((ally) => ally.id !== unit.id);
|
|
const focus = focusTarget(unit);
|
|
const currentFocusDistance = focus ? distance(unit, focus) : 0;
|
|
const safeTiles = tiles
|
|
.map((tile) => {
|
|
const threat = incomingThreat(unit, tile);
|
|
const nearestEnemyDistance =
|
|
liveUnits('enemy')
|
|
.map((enemy) => distance(tile, enemy))
|
|
.sort((a, b) => a - b)[0] ?? 99;
|
|
const allyDistance =
|
|
allies
|
|
.map((ally) => distance(tile, ally))
|
|
.sort((a, b) => a - b)[0] ?? 99;
|
|
const focusDistance = focus ? distance(tile, focus) : currentFocusDistance;
|
|
const overextendPenalty = focus && focusDistance < currentFocusDistance - 1 ? (currentFocusDistance - focusDistance) * 260 : 0;
|
|
return { tile, threat, nearestEnemyDistance, allyDistance, focusDistance, score: 0 - threat * 16 + nearestEnemyDistance * 38 - allyDistance * 24 - overextendPenalty };
|
|
})
|
|
.filter((entry) => entry.threat <= Math.floor(unit.hp * 0.24) && entry.nearestEnemyDistance >= 4 && entry.allyDistance <= 5);
|
|
|
|
return safeTiles.sort((a, b) => b.score - a.score || a.focusDistance - b.focusDistance || (a.tile.cost ?? 0) - (b.tile.cost ?? 0))[0]?.tile;
|
|
}
|
|
|
|
function scenarioAdvancePoint(unit) {
|
|
if (no === 5) {
|
|
const fortDone = (state().objectives ?? []).find((objective) => objective.id === 'fort')?.achieved;
|
|
if (!fortDone) {
|
|
return { x: 12, y: 1 };
|
|
}
|
|
}
|
|
if (no === 14) {
|
|
const objectives = state().objectives ?? [];
|
|
const relayDone = objectives.find((objective) => objective.id === 'relay')?.achieved;
|
|
const gateDone = objectives.find((objective) => objective.id === 'gate')?.achieved;
|
|
if (!relayDone) {
|
|
return { x: 18, y: 16 };
|
|
}
|
|
if (!gateDone) {
|
|
if (unit.x < 22 || unit.y > 18) {
|
|
return { x: 23, y: 18 };
|
|
}
|
|
return { x: 25, y: 13 };
|
|
}
|
|
}
|
|
if (no === 23) {
|
|
const objectives = state().objectives ?? [];
|
|
const southernVillage = objectives.find((objective) => objective.id === 'southern-village');
|
|
const southernRoad = objectives.find((objective) => objective.id === 'southern-road');
|
|
const yinglingGate = objectives.find((objective) => objective.id === 'yingling-gate');
|
|
if (unit.id === 'liu-bei' && southernVillage) {
|
|
return { x: 34, y: 18 };
|
|
}
|
|
if (southernVillage && !isObjectiveHeldByAlly(southernVillage)) {
|
|
return { x: 34, y: 18 };
|
|
}
|
|
if (southernRoad && !isObjectiveHeldByAlly(southernRoad)) {
|
|
return { x: 37, y: 18 };
|
|
}
|
|
if (yinglingGate && !isObjectiveHeldByAlly(yinglingGate)) {
|
|
return { x: 40, y: 19 };
|
|
}
|
|
}
|
|
if (no === 29) {
|
|
const routeDone = (state().objectives ?? []).find((objective) => objective.id === 'wu-yi-route')?.achieved;
|
|
if (!routeDone) {
|
|
return { x: 47, y: 23 };
|
|
}
|
|
}
|
|
if (no === 32) {
|
|
const objectives = state().objectives ?? [];
|
|
const outerVillage = objectives.find((objective) => objective.id === 'outer-village');
|
|
const passArchers = objectives.find((objective) => objective.id === 'pass-archers');
|
|
const mianzhuGate = objectives.find((objective) => objective.id === 'mianzhu-gate');
|
|
const mianzhuStorehouse = objectives.find((objective) => objective.id === 'mianzhu-storehouse');
|
|
if (outerVillage && !isObjectiveHeldByAlly(outerVillage)) {
|
|
return { x: 46, y: 27 };
|
|
}
|
|
if (passArchers && !isObjectiveHeldByAlly(passArchers)) {
|
|
return { x: 50, y: 18 };
|
|
}
|
|
if (mianzhuGate && !isObjectiveHeldByAlly(mianzhuGate)) {
|
|
return { x: 52, y: 22 };
|
|
}
|
|
if (mianzhuStorehouse && !isObjectiveHeldByAlly(mianzhuStorehouse)) {
|
|
return { x: 56, y: 30 };
|
|
}
|
|
const leaderDone = objectives.find((objective) => objective.id === 'leader')?.achieved;
|
|
if (!leaderDone && secureObjectivesSatisfied()) {
|
|
return { x: 60, y: 18 };
|
|
}
|
|
}
|
|
if (no === 33) {
|
|
const storehouse = (state().objectives ?? []).find((objective) => objective.id === 'chengdu-storehouse');
|
|
if (storehouse && !isObjectiveHeldByAlly(storehouse)) {
|
|
return { x: 59, y: 32 };
|
|
}
|
|
const leaderDone = (state().objectives ?? []).find((objective) => objective.id === 'leader')?.achieved;
|
|
if (!leaderDone && secureObjectivesSatisfied()) {
|
|
return { x: 62, y: 18 };
|
|
}
|
|
}
|
|
if (no >= 35 && no <= 66) {
|
|
const objectives = state().objectives ?? [];
|
|
if (no === 40) {
|
|
const riverBank = objectives.find((entry) => entry.id === 'river-bank');
|
|
if (riverBank && !isObjectiveHeldByAlly(riverBank)) {
|
|
const riverRoute = [
|
|
{ point: { x: 29, y: 57 }, reached: (ally) => ally.x >= 29 && ally.y >= 55 },
|
|
{ point: { x: 38, y: 59 }, reached: (ally) => ally.x >= 38 && ally.y >= 55 },
|
|
{ point: { x: 42, y: 56 }, reached: (ally) => ally.x >= 42 && ally.y <= 56 },
|
|
{ point: { x: 55, y: 48 }, reached: (ally) => ally.x >= 55 && ally.y <= 52 },
|
|
{ point: { x: 58, y: 40 }, reached: (ally) => ally.x >= 58 && ally.y <= 40 },
|
|
{ point: { x: 66, y: 40 }, reached: (ally) => ally.x >= 66 && ally.y <= 40 },
|
|
{ point: { x: 72, y: 32 }, reached: (ally) => ally.x >= 72 && ally.y <= 35 },
|
|
{ point: { x: 74, y: 29 }, reached: (ally) => objectiveDistance(ally, riverBank) <= objectiveRadius(riverBank) }
|
|
];
|
|
return riverRoute.find((step) => !liveUnits('ally').some((ally) => step.reached(ally)))?.point ?? { x: 74, y: 29 };
|
|
}
|
|
const leaderDone = objectives.find((objective) => objective.id === 'leader')?.achieved;
|
|
if (!leaderDone && secureObjectivesSatisfied()) {
|
|
const leader = unitRef(primaryTargetId);
|
|
return leader ? { x: leader.x, y: leader.y } : undefined;
|
|
}
|
|
}
|
|
if (no === 49) {
|
|
const amnestyVillages = objectives.find((entry) => entry.id === 'amnesty-villages');
|
|
const captiveCamps = objectives.find((entry) => entry.id === 'captive-camps');
|
|
if (amnestyVillages && !isObjectiveHeldByAlly(amnestyVillages)) {
|
|
if (unit.y < 39 || unit.x > 52) {
|
|
return { x: 50, y: 42 };
|
|
}
|
|
return { x: 49, y: 46 };
|
|
}
|
|
if (captiveCamps && !isObjectiveHeldByAlly(captiveCamps)) {
|
|
if (unit.x < 63) {
|
|
if (unit.y > 24) {
|
|
return { x: 55, y: 23 };
|
|
}
|
|
return { x: 63, y: 23 };
|
|
}
|
|
if (unit.y < 31) {
|
|
return { x: 64, y: 31 };
|
|
}
|
|
if (unit.y < 52) {
|
|
return { x: 66, y: 52 };
|
|
}
|
|
return { x: 66, y: 58 };
|
|
}
|
|
const leaderDone = objectives.find((objective) => objective.id === 'pacify-meng-huo-second')?.achieved;
|
|
if (!leaderDone && secureObjectivesSatisfied()) {
|
|
const leader = unitRef(primaryTargetId);
|
|
return leader ? { x: leader.x, y: leader.y } : undefined;
|
|
}
|
|
}
|
|
const objectiveRoutes = {
|
|
35: [{ id: 'yangping-storehouse', point: { x: 62, y: 28 } }],
|
|
36: [{ id: 'dingjun-ridge-storehouse', point: { x: 51, y: 35 } }],
|
|
37: [{ id: 'hanzhong-storehouse', point: { x: 69, y: 22 } }],
|
|
38: [{ id: 'jing-beacon', point: { x: 70, y: 40 } }],
|
|
39: [{ id: 'outer-fort', point: { x: 68, y: 34 } }],
|
|
40: [{ id: 'river-bank', point: { x: 74, y: 29 } }],
|
|
44: [{ id: 'maicheng-gate', point: { x: 26, y: 48 } }],
|
|
45: [
|
|
{ id: 'river-camp', point: { x: 52, y: 42 } },
|
|
{ id: 'yiling-road', point: { x: 74, y: 34 } }
|
|
],
|
|
46: [
|
|
{ id: 'fire-camps', point: { x: 49, y: 42 } },
|
|
{ id: 'retreat-road', point: { x: 14, y: 55 } }
|
|
],
|
|
47: [
|
|
{ id: 'villages', point: { x: 48, y: 45 } },
|
|
{ id: 'supply-camps', point: { x: 59, y: 56 } }
|
|
],
|
|
48: [
|
|
{ id: 'amnesty-villages', point: { x: 52, y: 48 } },
|
|
{ id: 'captive-camps', point: { x: 60, y: 59 } }
|
|
],
|
|
49: [
|
|
{ id: 'amnesty-villages', point: { x: 43, y: 46 } },
|
|
{ id: 'captive-camps', point: { x: 66, y: 58 } }
|
|
],
|
|
50: [
|
|
{ id: 'clan-villages', point: { x: 82, y: 53 } },
|
|
{ id: 'captive-camps', point: { x: 68, y: 60 } }
|
|
],
|
|
51: [
|
|
{ id: 'captive-camps', point: { x: 70, y: 62 } },
|
|
{ id: 'amnesty-route', point: { x: 79, y: 60 } },
|
|
{ id: 'fire-forts', point: { x: 86, y: 48 } }
|
|
],
|
|
52: [
|
|
{ id: 'clan-villages', point: { x: 52, y: 54 } },
|
|
{ id: 'hardliner-separation', point: { x: 80, y: 56 } },
|
|
{ id: 'hardliner-forts', point: { x: 86, y: 46 } }
|
|
],
|
|
53: [
|
|
{ id: 'village-trust', point: { x: 54, y: 56 } },
|
|
{ id: 'trust-road', point: { x: 82, y: 58 } },
|
|
{ id: 'hardliner-remnants', point: { x: 88, y: 48 } }
|
|
],
|
|
54: [
|
|
{ id: 'village-witnesses', point: { x: 56, y: 58 } },
|
|
{ id: 'council-road', point: { x: 84, y: 58 } },
|
|
{ id: 'final-remnant-forts', point: { x: 90, y: 50 } }
|
|
],
|
|
55: [
|
|
{ id: 'western-villages', point: { x: 52, y: 57 } },
|
|
{ id: 'qishan-road', point: { x: 62, y: 55 } },
|
|
{ id: 'hanzhong-supply-camp', point: { x: 78, y: 69 } }
|
|
],
|
|
56: [
|
|
{ id: 'tianshui-road', point: { x: 76, y: 54 } },
|
|
{ id: 'waterline-villages', point: { x: 92, y: 56 } },
|
|
{ id: 'jieting-forts', point: { x: 89, y: 40 } }
|
|
],
|
|
57: [
|
|
{ id: 'jieting-waterline', point: { x: 82, y: 72 } },
|
|
{ id: 'ridge-camps', point: { x: 80, y: 66 } },
|
|
{ id: 'ridge-forts', point: { x: 66, y: 44 } }
|
|
],
|
|
58: [
|
|
{ id: 'rear-camps', point: { x: 30, y: 80 } },
|
|
{ id: 'qishan-cart-road', point: { x: 56, y: 74 } },
|
|
{ id: 'qishan-waterline', point: { x: 66, y: 90 } },
|
|
{ id: 'ridge-forts', point: { x: 56, y: 44 } }
|
|
],
|
|
59: [
|
|
{ id: 'rear-villages', point: { x: 20, y: 85 } },
|
|
{ id: 'grain-road', point: { x: 56, y: 76 } },
|
|
{ id: 'siege-camps', point: { x: 64, y: 68 } },
|
|
{ id: 'chencang-gate', point: { x: 101, y: 48 } }
|
|
],
|
|
60: [
|
|
{ id: 'two-commandery-road', point: { x: 84, y: 58 } },
|
|
{ id: 'wudu-yinping-villages', point: { x: 87, y: 62 } },
|
|
{ id: 'mountain-camps', point: { x: 97, y: 62 } }
|
|
],
|
|
61: [
|
|
{ id: 'hanzhong-granary-camps', point: { x: 67, y: 72 } },
|
|
{ id: 'rain-pass-roads', point: { x: 88, y: 58 } },
|
|
{ id: 'front-villages', point: { x: 109, y: 49 } }
|
|
],
|
|
62: [
|
|
{ id: 'qishan-supply-camps', point: { x: 68, y: 72 } },
|
|
{ id: 'lucheng-front-villages', point: { x: 111, y: 50 } }
|
|
],
|
|
63: [
|
|
{ id: 'hold-return-supply-camps', point: { x: 70, y: 76 } },
|
|
{ id: 'reassure-lucheng-villages', point: { x: 113, y: 52 } }
|
|
],
|
|
64: [
|
|
{ id: 'hold-weishui-supply-camps', point: { x: 70, y: 78 } },
|
|
{ id: 'reassure-river-villages', point: { x: 96, y: 70 } }
|
|
],
|
|
65: [
|
|
{ id: 'hold-northbank-forward-camps', point: { x: 112, y: 66 } },
|
|
{ id: 'reassure-northbank-villages', point: { x: 97, y: 72 } }
|
|
],
|
|
66: [
|
|
{ id: 'preserve-wuzhang-camps', point: { x: 38, y: 96 } },
|
|
{ id: 'protect-returning-villages', point: { x: 25, y: 101 } }
|
|
]
|
|
};
|
|
const routes = objectiveRoutes[no] ?? [];
|
|
for (const route of routes) {
|
|
const objective = objectives.find((entry) => entry.id === route.id);
|
|
if (objective && !isObjectiveHeldByAlly(objective)) {
|
|
return route.point;
|
|
}
|
|
}
|
|
const leaderDone = objectives.find((objective) => objective.id === 'leader')?.achieved;
|
|
if (!leaderDone && secureObjectivesSatisfied()) {
|
|
const leader = no >= 60 && no <= 66 ? nextRequiredTarget(unit) : unitRef(primaryTargetId);
|
|
return leader ? { x: leader.x, y: leader.y } : undefined;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function scenarioHoldTile(unit) {
|
|
if (no !== 23 || unit.id !== 'liu-bei') {
|
|
return undefined;
|
|
}
|
|
const southernVillage = (state().objectives ?? []).find((objective) => objective.id === 'southern-village');
|
|
const hasAnotherAlly = liveUnits('ally').some((ally) => ally.id !== unit.id);
|
|
if (
|
|
southernVillage &&
|
|
hasAnotherAlly &&
|
|
liveUnits('enemy').length > 0 &&
|
|
objectiveDistance(unit, southernVillage) <= objectiveRadius(southernVillage)
|
|
) {
|
|
return { x: unit.x, y: unit.y, cost: 0 };
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function advancePointTile(unit, tiles) {
|
|
const point = scenarioAdvancePoint(unit);
|
|
const protectedScenarioPush = no >= 32 && no <= 66;
|
|
if (!point || (protectedIds.has(unit.id) && !isLastProtectedFighter(unit) && !protectedScenarioPush)) {
|
|
return undefined;
|
|
}
|
|
const routeTarget = {
|
|
...unit,
|
|
id: `qa-route-${no}-${point.x}-${point.y}`,
|
|
faction: 'enemy',
|
|
x: point.x,
|
|
y: point.y,
|
|
hp: 1,
|
|
maxHp: 1
|
|
};
|
|
const pathTile = scene.chooseApproachTile?.(unit, routeTarget);
|
|
if (
|
|
pathTile &&
|
|
(pathTile.x !== unit.x || pathTile.y !== unit.y) &&
|
|
(!shouldAvoidOccupiedRouteTiles || !tileIsOccupiedByOther(unit, pathTile))
|
|
) {
|
|
return pathTile;
|
|
}
|
|
const currentDistance = distance(unit, point);
|
|
return tiles
|
|
.filter((tile) => tile.x !== unit.x || tile.y !== unit.y)
|
|
.filter((tile) => !shouldAvoidOccupiedRouteTiles || !tileIsOccupiedByOther(unit, tile))
|
|
.filter((tile) => distance(tile, point) < currentDistance)
|
|
.sort((a, b) => distance(a, point) - distance(b, point) || (a.cost ?? 0) - (b.cost ?? 0))[0];
|
|
}
|
|
|
|
function chooseMove(unit) {
|
|
const reachableTiles = scene.reachableTiles(unit);
|
|
const tiles = [
|
|
{ x: unit.x, y: unit.y, cost: 0 },
|
|
...(shouldAvoidOccupiedRouteTiles ? reachableTiles.filter((tile) => !tileIsOccupiedByOther(unit, tile)) : reachableTiles)
|
|
];
|
|
const pressProtected = shouldPressProtected(unit);
|
|
const canDriveObjective = !protectedIds.has(unit.id) || pressProtected || isLastProtectedFighter(unit);
|
|
const scenarioRouteTile = no >= 40 && no <= 66 ? advancePointTile(unit, tiles) : undefined;
|
|
if (scenarioRouteTile) {
|
|
return scenarioRouteTile;
|
|
}
|
|
const anchorTile = protectedAnchorTile(unit, tiles, pressProtected);
|
|
if (anchorTile) {
|
|
return anchorTile;
|
|
}
|
|
|
|
if (isLastProtectedFighter(unit)) {
|
|
const nearestEnemy = liveUnits('enemy').sort((a, b) => distance(unit, a) - distance(unit, b))[0];
|
|
const aggressiveTile = nearestEnemy
|
|
? tiles
|
|
.filter((tile) => tile.x !== unit.x || tile.y !== unit.y)
|
|
.sort((a, b) => distance(a, nearestEnemy) - distance(b, nearestEnemy) || (a.cost ?? 0) - (b.cost ?? 0))[0]
|
|
: undefined;
|
|
if (aggressiveTile && (!nearestEnemy || distance(aggressiveTile, nearestEnemy) <= distance(unit, nearestEnemy) + 2)) {
|
|
return aggressiveTile;
|
|
}
|
|
}
|
|
|
|
const scenarioHold = scenarioHoldTile(unit);
|
|
if (scenarioHold) {
|
|
return scenarioHold;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const scenarioTile = advancePointTile(unit, tiles);
|
|
if (scenarioTile) {
|
|
return scenarioTile;
|
|
}
|
|
|
|
if (canDriveObjective) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
const objective = currentSecureObjective();
|
|
if (objective && canDriveObjective) {
|
|
const currentObjectiveDistance = objectiveDistance(unit, objective);
|
|
const objectivePressureTile = tiles
|
|
.filter((tile) => tile.x !== unit.x || tile.y !== unit.y)
|
|
.filter((tile) => objectiveDistance(tile, objective) < currentObjectiveDistance)
|
|
.sort((a, b) => objectiveDistance(a, objective) - objectiveDistance(b, objective) || (a.cost ?? 0) - (b.cost ?? 0))[0];
|
|
if (objectivePressureTile) {
|
|
return objectivePressureTile;
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
function allyTurnPriority(unit) {
|
|
const protectedPenalty = protectedIds.has(unit.id) ? 80 : 0;
|
|
const vanguardBonus = ['guan-yu', 'zhang-fei', 'zhao-yun', 'huang-zhong', 'wei-yan', 'ma-chao', 'ma-dai'].includes(unit.id) ? -28 : 0;
|
|
const supportPenalty = ['jian-yong', 'mi-zhu', 'sun-qian', 'ma-liang', 'yi-ji', 'gong-zhi'].includes(unit.id) ? 20 : 0;
|
|
return protectedPenalty + supportPenalty + vanguardBonus;
|
|
}
|
|
|
|
for (let round = 0; round < maxRounds; round += 1) {
|
|
stabilizeRepresentativeBattleIfNeeded(round);
|
|
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) => allyTurnPriority(a) - allyTurnPriority(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;
|
|
}
|
|
|
|
if (no >= 14 && shouldHoldBattleEndingTarget(enemy, currentSecureObjective())) {
|
|
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;
|
|
}
|
|
|
|
function printQaSummary(results, failed, missedObjectives) {
|
|
const totals = results.reduce(
|
|
(summary, result) => {
|
|
summary.turns += result.finalTurn ?? 0;
|
|
summary.objectives += result.objectiveCount ?? 0;
|
|
summary.objectivesAchieved += result.objectivesAchieved ?? 0;
|
|
summary.itemsUsed += result.itemsUsed ?? 0;
|
|
summary.supports += result.supports ?? 0;
|
|
|
|
if (result.campSuppliesUsed) {
|
|
summary.camp.bean += result.campSuppliesUsed.bean ?? 0;
|
|
summary.camp.salve += result.campSuppliesUsed.salve ?? 0;
|
|
summary.camp.wine += result.campSuppliesUsed.wine ?? 0;
|
|
}
|
|
|
|
return summary;
|
|
},
|
|
{
|
|
turns: 0,
|
|
objectives: 0,
|
|
objectivesAchieved: 0,
|
|
itemsUsed: 0,
|
|
supports: 0,
|
|
camp: { bean: 0, salve: 0, wine: 0 }
|
|
}
|
|
);
|
|
const turnValues = results.map((result) => result.finalTurn ?? 0).filter((turn) => turn > 0);
|
|
const longestBattle = results.reduce((current, result) => {
|
|
if (!current || (result.finalTurn ?? 0) > (current.finalTurn ?? 0)) {
|
|
return result;
|
|
}
|
|
return current;
|
|
}, undefined);
|
|
const latestCampaign = results.at(-1)?.campaign;
|
|
|
|
console.log(
|
|
`QA summary: battles=${results.length} victories=${results.filter((result) => result.outcome === 'victory').length} failures=${failed.length} objectives=${totals.objectivesAchieved}/${totals.objectives} missed=${missedObjectives.length}`
|
|
);
|
|
|
|
if (turnValues.length > 0) {
|
|
const averageTurns = (totals.turns / turnValues.length).toFixed(1);
|
|
console.log(
|
|
`QA pace: avgTurn=${averageTurns} minTurn=${Math.min(...turnValues)} maxTurn=${Math.max(...turnValues)} longest=${longestBattle?.no}:${longestBattle?.id}`
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`QA resources: items=${totals.itemsUsed} supports=${totals.supports} campUsed=${totals.camp.bean}/${totals.camp.salve}/${totals.camp.wine}`
|
|
);
|
|
|
|
if (latestCampaign) {
|
|
const topLevels = Array.isArray(latestCampaign.topLevels) ? latestCampaign.topLevels.join(',') : 'none';
|
|
console.log(
|
|
`QA campaign: battles=${latestCampaign.battleCount} gold=${latestCampaign.gold} roster=${latestCampaign.rosterCount} level=${latestCampaign.levelMin}-${latestCampaign.levelMax} avg=${latestCampaign.levelAvg} lowHp=${latestCampaign.lowHp}`
|
|
);
|
|
console.log(
|
|
`QA stock: bean=${latestCampaign.supplies.bean} salve=${latestCampaign.supplies.salve} wine=${latestCampaign.supplies.wine} topLevels=${topLevels}`
|
|
);
|
|
}
|
|
|
|
if (failed.length > 0) {
|
|
console.log(`QA failures: ${failed.map((result) => `${result.no}:${result.id}:${result.outcome}`).join(' | ')}`);
|
|
}
|
|
|
|
if (missedObjectives.length > 0) {
|
|
console.log(`QA missed objectives: ${missedObjectives.map((objective) => `${objective.no}:${objective.objectiveId}`).join(' | ')}`);
|
|
}
|
|
}
|
|
|
|
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 ?? ''
|
|
}))
|
|
);
|
|
const summary = buildQaReportSummary(results, failed, missedObjectives);
|
|
|
|
return {
|
|
app: 'heros_web',
|
|
generatedAt: new Date().toISOString(),
|
|
set: qaSetName,
|
|
targetUrl,
|
|
headless,
|
|
maxRounds,
|
|
cumulativeMode,
|
|
requestedBattles: [...requestedBattles],
|
|
battleCount: results.length,
|
|
summary,
|
|
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
|
|
}))
|
|
};
|
|
}
|
|
|
|
function buildQaReportSummary(results, failed, missedObjectives) {
|
|
const turnValues = results.map((result) => result.finalTurn ?? 0).filter((turn) => turn > 0);
|
|
const totalTurns = turnValues.reduce((total, turn) => total + turn, 0);
|
|
const longestBattle = results.reduce((current, result) => {
|
|
if (!current || (result.finalTurn ?? 0) > (current.finalTurn ?? 0)) {
|
|
return result;
|
|
}
|
|
return current;
|
|
}, undefined);
|
|
const totals = results.reduce(
|
|
(summary, result) => {
|
|
summary.objectiveCount += result.objectiveCount ?? 0;
|
|
summary.objectivesAchieved += result.objectivesAchieved ?? 0;
|
|
summary.itemsUsed += result.itemsUsed ?? 0;
|
|
summary.supports += result.supports ?? 0;
|
|
if (result.campSuppliesUsed) {
|
|
summary.campSuppliesUsed.bean += result.campSuppliesUsed.bean ?? 0;
|
|
summary.campSuppliesUsed.salve += result.campSuppliesUsed.salve ?? 0;
|
|
summary.campSuppliesUsed.wine += result.campSuppliesUsed.wine ?? 0;
|
|
}
|
|
return summary;
|
|
},
|
|
{
|
|
objectiveCount: 0,
|
|
objectivesAchieved: 0,
|
|
itemsUsed: 0,
|
|
supports: 0,
|
|
campSuppliesUsed: { bean: 0, salve: 0, wine: 0 }
|
|
}
|
|
);
|
|
const latestCampaign = results.at(-1)?.campaign;
|
|
|
|
return {
|
|
victories: results.filter((result) => result.outcome === 'victory').length,
|
|
failures: failed.length,
|
|
missedObjectiveCount: missedObjectives.length,
|
|
battlesWithMissedObjectives: [...new Set(missedObjectives.map((objective) => objective.battleNo))],
|
|
objectivesAchieved: totals.objectivesAchieved,
|
|
objectiveCount: totals.objectiveCount,
|
|
turns: {
|
|
total: totalTurns,
|
|
average: turnValues.length > 0 ? Number((totalTurns / turnValues.length).toFixed(1)) : 0,
|
|
min: turnValues.length > 0 ? Math.min(...turnValues) : 0,
|
|
max: turnValues.length > 0 ? Math.max(...turnValues) : 0,
|
|
longestBattle: longestBattle ? { no: longestBattle.no, id: longestBattle.id, turn: longestBattle.finalTurn ?? 0 } : null
|
|
},
|
|
resources: {
|
|
itemsUsed: totals.itemsUsed,
|
|
supports: totals.supports,
|
|
campSuppliesUsed: totals.campSuppliesUsed
|
|
},
|
|
latestCampaign: latestCampaign
|
|
? {
|
|
battleCount: latestCampaign.battleCount,
|
|
gold: latestCampaign.gold,
|
|
supplies: latestCampaign.supplies,
|
|
rosterCount: latestCampaign.rosterCount,
|
|
levelMin: latestCampaign.levelMin,
|
|
levelMax: latestCampaign.levelMax,
|
|
levelAvg: latestCampaign.levelAvg,
|
|
lowHp: latestCampaign.lowHp,
|
|
topLevels: latestCampaign.topLevels
|
|
}
|
|
: null
|
|
};
|
|
}
|