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 = withCanvasRenderer(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 baselineViewport = { width: 1920, height: 1080 }; function withCanvasRenderer(url) { const parsed = new URL(url); parsed.searchParams.set('renderer', 'canvas'); return parsed.toString(); } const supplyLabels = { bean: '\uCF69', salve: '\uC0C1\uCC98\uC57D', wine: '\uD0C1\uC8FC' }; async function clickBattleDeploymentStart(page) { const point = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const layout = scene?.layout; const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); if (!scene || !layout || !canvas || !bounds) { return null; } const logicalX = layout.panelX + 24 + (layout.panelWidth - 48) / 2; const logicalY = layout.panelY + layout.panelHeight - 76 + 17; return { x: bounds.left + logicalX * bounds.width / scene.scale.width, y: bounds.top + logicalY * bounds.height / scene.scale.height }; }); if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { throw new Error(`Expected a runtime deployment start point: ${JSON.stringify(point)}`); } await page.mouse.click(point.x, point.y); } 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: baselineViewport }); 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 clickBattleDeploymentStart(page); 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 }; }