Balance cumulative campaign flow

This commit is contained in:
2026-06-28 08:11:08 +09:00
parent fcc2e28e05
commit 87a25ffbc1
5 changed files with 479 additions and 87 deletions

View File

@@ -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 }) => {