Balance cumulative campaign flow
This commit is contained in:
@@ -1,10 +1,22 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
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 cumulativeMode = process.env.QA_CUMULATIVE === '1';
|
||||
const campaignStorageKey = 'heros-web:campaign-state';
|
||||
const campaignSlotStorageKey = 'heros-web:campaign-state:slot-1';
|
||||
const campaignSnapshotPath = process.env.QA_CAMPAIGN_SNAPSHOT_PATH;
|
||||
const resumeCampaignSnapshot = process.env.QA_RESUME_CAMPAIGN_SNAPSHOT === '1';
|
||||
const supplyLabels = {
|
||||
bean: '\uCF69',
|
||||
salve: '\uC0C1\uCC98\uC57D',
|
||||
wine: '\uD0C1\uC8FC'
|
||||
};
|
||||
|
||||
const coreBrothers = ['liu-bei', 'guan-yu', 'zhang-fei'];
|
||||
const xuzhouFullSortie = ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu'];
|
||||
@@ -47,6 +59,18 @@ const mengHuoFourthSortie = ['zhuge-liang', 'ma-liang', 'huang-quan', 'wang-ping
|
||||
const mengHuoFifthSortie = ['zhuge-liang', 'huang-quan', 'ma-liang', 'wang-ping', 'zhao-yun', 'huang-zhong', 'ma-dai'];
|
||||
const mengHuoSixthSortie = ['zhuge-liang', 'ma-liang', 'huang-quan', 'wang-ping', 'zhao-yun', 'wei-yan', 'ma-dai'];
|
||||
const mengHuoFinalSortie = ['zhuge-liang', 'huang-quan', 'ma-liang', 'wang-ping', 'zhao-yun', 'ma-dai', 'huang-zhong'];
|
||||
const northernFirstSortie = ['zhuge-liang', 'zhao-yun', 'huang-quan', 'ma-liang', 'wang-ping', 'ma-dai', 'wei-yan'];
|
||||
const northernSecondSortie = ['zhuge-liang', 'zhao-yun', 'huang-quan', 'ma-liang', 'wang-ping', 'ma-dai', 'wei-yan'];
|
||||
const northernThirdSortie = ['zhuge-liang', 'wang-ping', 'zhao-yun', 'huang-quan', 'ma-liang', 'ma-dai', 'wei-yan'];
|
||||
const northernFourthSortie = ['zhuge-liang', 'jiang-wei', 'wang-ping', 'zhao-yun', 'ma-dai', 'huang-quan', 'ma-liang'];
|
||||
const northernFifthSortie = ['zhuge-liang', 'jiang-wei', 'wei-yan', 'wang-ping', 'ma-dai', 'huang-quan', 'fa-zheng'];
|
||||
const northernSixthSortie = ['zhuge-liang', 'jiang-wei', 'wei-yan', 'zhao-yun', 'wang-ping', 'huang-quan', 'ma-liang'];
|
||||
const northernSeventhSortie = ['zhuge-liang', 'jiang-wei', 'wang-ping', 'huang-quan', 'li-yan', 'ma-liang', 'wei-yan'];
|
||||
const northernEighthSortie = ['zhuge-liang', 'jiang-wei', 'wei-yan', 'zhao-yun', 'wang-ping', 'huang-quan', 'ma-liang'];
|
||||
const northernNinthSortie = ['zhuge-liang', 'jiang-wei', 'zhao-yun', 'wei-yan', 'wang-ping', 'huang-quan', 'li-yan'];
|
||||
const northernTenthSortie = ['zhuge-liang', 'jiang-wei', 'wang-ping', 'huang-quan', 'li-yan', 'wei-yan', 'ma-dai'];
|
||||
const northernEleventhSortie = ['zhuge-liang', 'jiang-wei', 'ma-dai', 'wang-ping', 'huang-quan', 'li-yan', 'wei-yan'];
|
||||
const northernFinalSortie = ['zhuge-liang', 'jiang-wei', 'wang-ping', 'ma-dai', 'huang-quan', 'li-yan', 'wei-yan'];
|
||||
|
||||
const earlyCampaignBattles = [
|
||||
{ no: 1, id: 'first-battle-zhuo-commandery', selected: [], targets: ['rebel-leader'], protected: ['liu-bei'] },
|
||||
@@ -438,6 +462,103 @@ const nanzhongFinalCampaignBattles = [
|
||||
}
|
||||
];
|
||||
|
||||
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'] },
|
||||
{
|
||||
@@ -500,12 +621,14 @@ const allRepresentativeBattles = [
|
||||
|
||||
const qaBattleSets = {
|
||||
representative: allRepresentativeBattles,
|
||||
campaign: allCampaignBattles,
|
||||
early: earlyCampaignBattles,
|
||||
refuge: refugeCampaignBattles,
|
||||
southern: southernCampaignBattles,
|
||||
hanzhong: hanzhongCampaignBattles,
|
||||
yiling: yilingCampaignBattles,
|
||||
nanzhongFinal: nanzhongFinalCampaignBattles
|
||||
nanzhongFinal: nanzhongFinalCampaignBattles,
|
||||
northern: northernCampaignBattles
|
||||
};
|
||||
const qaSetName = process.env.QA_SET ?? 'representative';
|
||||
const qaBattleSet = qaBattleSets[qaSetName];
|
||||
@@ -536,12 +659,27 @@ try {
|
||||
|
||||
for (const battle of representativeBattles) {
|
||||
console.log(`QA battle ${battle.no}: ${battle.id}`);
|
||||
await setupBattle(page, battle);
|
||||
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}`
|
||||
: '';
|
||||
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}`
|
||||
`QA result ${result.no}: ${result.outcome} turn=${result.finalTurn} allies=${result.alliesAlive} enemies=${result.enemiesAlive} items=${result.itemsUsed} objectives=${result.objectivesAchieved}/${result.objectiveCount}${stockLine}`
|
||||
);
|
||||
if (result.outcome !== 'victory') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter((result) => result.outcome !== 'victory');
|
||||
@@ -553,11 +691,35 @@ try {
|
||||
alliesAlive: result.alliesAlive,
|
||||
enemiesAlive: result.enemiesAlive,
|
||||
itemsUsed: result.itemsUsed,
|
||||
campUsed: result.campSuppliesUsed
|
||||
? `${result.campSuppliesUsed.bean}/${result.campSuppliesUsed.salve}/${result.campSuppliesUsed.wine}`
|
||||
: '',
|
||||
stock: result.campaign ? `${result.campaign.supplies.bean}/${result.campaign.supplies.salve}/${result.campaign.supplies.wine}` : '',
|
||||
level: result.campaign ? `${result.campaign.levelMin}-${result.campaign.levelMax} (${result.campaign.levelAvg})` : '',
|
||||
lowHp: result.campaign?.lowHp ?? '',
|
||||
supports: result.supports,
|
||||
objectives: `${result.objectivesAchieved}/${result.objectiveCount}`
|
||||
}))
|
||||
);
|
||||
|
||||
if (cumulativeMode && results.length > 0) {
|
||||
const latest = results.at(-1).campaign;
|
||||
console.dir(
|
||||
{
|
||||
mode: 'cumulative',
|
||||
battleCount: latest?.battleCount,
|
||||
gold: latest?.gold,
|
||||
supplies: latest?.supplies,
|
||||
rosterCount: latest?.rosterCount,
|
||||
levelRange: latest ? `${latest.levelMin}-${latest.levelMax}` : undefined,
|
||||
levelAvg: latest?.levelAvg,
|
||||
lowHp: latest?.lowHp,
|
||||
topLevels: latest?.topLevels
|
||||
},
|
||||
{ depth: null }
|
||||
);
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.dir(
|
||||
failed.map((result) => ({
|
||||
@@ -622,6 +784,189 @@ async function setupBattle(page, battle) {
|
||||
);
|
||||
}
|
||||
|
||||
async function setupCumulativeBattle(page, battle, firstBattle) {
|
||||
const initialSnapshot =
|
||||
firstBattle && resumeCampaignSnapshot && campaignSnapshotPath && existsSync(campaignSnapshotPath)
|
||||
? await readFile(campaignSnapshotPath, 'utf8')
|
||||
: undefined;
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 });
|
||||
const preparation = await page.evaluate(
|
||||
({ firstBattle, initialSnapshot, labels, protectedUnitIds, selected, slotKey, storageKey }) => {
|
||||
const now = new Date().toISOString();
|
||||
const defaultState = {
|
||||
version: 1,
|
||||
updatedAt: now,
|
||||
step: 'first-battle',
|
||||
activeSaveSlot: 1,
|
||||
gold: 0,
|
||||
roster: [],
|
||||
bonds: [],
|
||||
inventory: {},
|
||||
selectedSortieUnitIds: selected,
|
||||
reserveTrainingFocus: 'balanced',
|
||||
completedCampDialogues: [],
|
||||
completedCampVisits: [],
|
||||
battleHistory: {}
|
||||
};
|
||||
|
||||
if (firstBattle && !initialSnapshot) {
|
||||
window.localStorage.clear();
|
||||
}
|
||||
|
||||
const rawState = initialSnapshot ?? (firstBattle ? undefined : window.localStorage.getItem(slotKey) ?? window.localStorage.getItem(storageKey));
|
||||
const state = rawState ? JSON.parse(rawState) : defaultState;
|
||||
const suppliesUsed = { bean: 0, salve: 0, wine: 0 };
|
||||
state.updatedAt = now;
|
||||
state.activeSaveSlot = 1;
|
||||
state.inventory = state.inventory ?? {};
|
||||
state.roster = Array.isArray(state.roster) ? state.roster : [];
|
||||
state.selectedSortieUnitIds = [...new Set(selected)];
|
||||
state.reserveTrainingFocus = state.reserveTrainingFocus ?? 'balanced';
|
||||
|
||||
const selectedIds = new Set(selected);
|
||||
const protectedIds = new Set(protectedUnitIds);
|
||||
const healAmounts = {
|
||||
[labels.bean]: 12,
|
||||
[labels.salve]: 22,
|
||||
[labels.wine]: 8
|
||||
};
|
||||
|
||||
const consume = (label) => {
|
||||
if ((state.inventory[label] ?? 0) <= 0) {
|
||||
return false;
|
||||
}
|
||||
state.inventory[label] -= 1;
|
||||
if (state.inventory[label] <= 0) {
|
||||
delete state.inventory[label];
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const applySupply = (unit, supplyId, label) => {
|
||||
if (!consume(label)) {
|
||||
return false;
|
||||
}
|
||||
unit.hp = Math.min(unit.maxHp, unit.hp + healAmounts[label]);
|
||||
suppliesUsed[supplyId] += 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const unit of state.roster) {
|
||||
if (!selectedIds.has(unit.id) && !protectedIds.has(unit.id)) {
|
||||
continue;
|
||||
}
|
||||
if (typeof unit.hp !== 'number' || typeof unit.maxHp !== 'number' || unit.maxHp <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetRatio = protectedIds.has(unit.id) ? 1 : 0.86;
|
||||
const targetHp = Math.min(unit.maxHp, Math.ceil(unit.maxHp * targetRatio));
|
||||
while (unit.hp < targetHp) {
|
||||
const missing = targetHp - unit.hp;
|
||||
const ratio = unit.hp / unit.maxHp;
|
||||
if (missing >= 17 && (state.inventory[labels.salve] ?? 0) > 0) {
|
||||
applySupply(unit, 'salve', labels.salve);
|
||||
} else if (missing >= 9 && (state.inventory[labels.bean] ?? 0) > 0) {
|
||||
applySupply(unit, 'bean', labels.bean);
|
||||
} else if (ratio < 0.7 && missing >= 5 && (state.inventory[labels.wine] ?? 0) > 0) {
|
||||
applySupply(unit, 'wine', labels.wine);
|
||||
} else if (protectedIds.has(unit.id) && (state.inventory[labels.bean] ?? 0) > 0) {
|
||||
applySupply(unit, 'bean', labels.bean);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(state));
|
||||
window.localStorage.setItem(slotKey, JSON.stringify(state));
|
||||
return {
|
||||
suppliesUsed,
|
||||
stock: {
|
||||
bean: state.inventory[labels.bean] ?? 0,
|
||||
salve: state.inventory[labels.salve] ?? 0,
|
||||
wine: state.inventory[labels.wine] ?? 0
|
||||
}
|
||||
};
|
||||
},
|
||||
{
|
||||
firstBattle,
|
||||
initialSnapshot,
|
||||
labels: supplyLabels,
|
||||
protectedUnitIds: battle.protected,
|
||||
selected: battle.selected,
|
||||
slotKey: campaignSlotStorageKey,
|
||||
storageKey: campaignStorageKey
|
||||
}
|
||||
);
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 });
|
||||
await page.evaluate((battleId) => window.__HEROS_DEBUG__.goToBattle(battleId), battle.id);
|
||||
await page.waitForFunction(
|
||||
(battleId) => {
|
||||
const state = window.__HEROS_DEBUG__?.battle();
|
||||
return state?.scene === 'BattleScene' && state?.battleId === battleId && state?.phase === 'idle' && state?.mapBackgroundReady === true;
|
||||
},
|
||||
battle.id,
|
||||
{ timeout: 90000 }
|
||||
);
|
||||
return preparation;
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user