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