feat: add post-battle formation review

This commit is contained in:
2026-07-10 22:04:03 +09:00
parent 7ba51d9243
commit 78669d3c6e
4 changed files with 1232 additions and 6 deletions

View File

@@ -106,6 +106,59 @@ try {
);
assert(!resultProbe.resultTexts.some((text) => text.includes('미달')), `Expected result screen to avoid harsh optional-goal wording: ${JSON.stringify(resultProbe.resultTexts)}`);
const firstResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const firstResultSave = await readCampaignSave(page);
const firstResultPerformance = firstResultState?.sortieEvaluation?.snapshot;
assert(
firstResultState?.sortieEvaluation?.available === true &&
firstResultState.sortieEvaluation.open === false &&
['S', 'A', 'B', 'C', 'D'].includes(firstResultState.sortieEvaluation.grade) &&
Number.isFinite(firstResultState.sortieEvaluation.score),
`Expected the first result to expose a closed formation evaluation with a grade: ${JSON.stringify(firstResultState?.sortieEvaluation)}`
);
assert(
firstResultPerformance?.selectedUnitIds?.length === firstResultState?.deployedAllyIds?.length &&
firstResultPerformance.selectedUnitIds.every((unitId) => firstResultState.deployedAllyIds.includes(unitId)) &&
firstResultPerformance.selectedUnitIds.every((unitId) => ['front', 'flank', 'support', 'reserve'].includes(firstResultPerformance.formationAssignments?.[unitId])) &&
firstResultPerformance.unitStats?.length === firstResultPerformance.selectedUnitIds.length &&
!Object.prototype.hasOwnProperty.call(firstResultPerformance, 'sortieItemAssignments') &&
!Object.prototype.hasOwnProperty.call(firstResultPerformance, 'itemAssignments'),
`Expected the first result snapshot to materialize every deployed officer and role without supplies: ${JSON.stringify(firstResultPerformance)}`
);
assert(
sameJsonValue(firstResultSave.current?.firstBattleReport?.sortiePerformance, firstResultPerformance) &&
sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortiePerformance, firstResultPerformance) &&
sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortiePerformance, firstResultPerformance) &&
sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortiePerformance, firstResultPerformance),
`Expected the first formation result snapshot to synchronize across report, history, current, and slot-1 saves: ${JSON.stringify(firstResultSave)}`
);
const firstEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle');
await page.mouse.click(firstEvaluationTogglePoint.x, firstEvaluationTogglePoint.y);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true, undefined, { timeout: 30000 });
const firstEvaluationProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const state = window.__HEROS_DEBUG__?.battle();
return {
evaluation: state?.sortieEvaluation,
texts: (scene?.resultFormationReviewObjects ?? [])
.filter((object) => object?.type === 'Text')
.map((object) => object.text)
};
});
assert(
firstEvaluationProbe.texts.includes('이번 편성 평가') &&
firstEvaluationProbe.texts.some((text) => text.includes('명단·역할만 저장') && text.includes('장비·보급 제외')) &&
firstEvaluationProbe.evaluation?.presetCards?.length === 3 &&
firstEvaluationProbe.evaluation.presetCards.every((card) => card.saved === false && card.matching === false && card.actionButtonBounds),
`Expected the visible first-battle evaluation to explain its scope and show three empty preset actions: ${JSON.stringify(firstEvaluationProbe)}`
);
await page.screenshot({ path: `${screenshotDir}/rc-first-battle-formation-evaluation.png`, fullPage: true });
await assertCanvasPainted(page, 'first battle formation evaluation');
const firstEvaluationClosePoint = await readBattleResultEvaluationControlPoint(page, 'close');
await page.mouse.click(firstEvaluationClosePoint.x, firstEvaluationClosePoint.y);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false, undefined, { timeout: 30000 });
await page.mouse.click(738, 642);
await waitForCampAfterBattleResult(page);
await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true });
@@ -1111,6 +1164,205 @@ try {
`Expected a non-Peach-Garden officer to receive a generic sortie role effect: ${JSON.stringify(midNextBattleProbe.units)}`
);
const midResultFixture = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const state = window.__HEROS_DEBUG__?.battle();
const contributor = state?.units?.find((unit) =>
unit.faction === 'ally' && ['front', 'flank', 'support'].includes(unit.formationRole)
);
const wounded = state?.units?.find((unit) =>
unit.faction === 'ally' && unit.id !== contributor?.id && unit.hp > 1
) ?? contributor;
const liveWounded = wounded?.id ? scene?.debugUnitById?.(wounded.id) : undefined;
if (!scene || !contributor || !liveWounded || typeof scene.statsFor !== 'function') {
return { ready: false, contributorId: contributor?.id ?? null, woundedUnitId: wounded?.id ?? null };
}
const stats = scene.statsFor(contributor.id);
Object.assign(stats, {
damageDealt: 137,
damageTaken: 22,
defeats: 3,
actions: 5,
support: 11,
sortieBonusDamage: 17,
sortiePreventedDamage: 9,
sortieBonusHealing: 4,
sortieExtendedBondTriggers: 2,
intentDefeats: 1,
intentLineBreaks: 1,
intentGuardSuccesses: 1
});
liveWounded.hp = Math.max(1, liveWounded.maxHp - 7);
return {
ready: true,
contributorId: contributor.id,
woundedUnitId: liveWounded.id,
woundedHp: liveWounded.hp,
woundedMaxHp: liveWounded.maxHp,
expectedStats: {
damageDealt: 137,
damageTaken: 22,
defeats: 3,
actions: 5,
support: 11,
sortieBonusDamage: 17,
sortiePreventedDamage: 9,
sortieBonusHealing: 4,
sortieExtendedBondTriggers: 2,
intentDefeats: 1,
intentLineBreaks: 1,
intentGuardSuccesses: 1
}
};
});
assert(
midResultFixture.ready === true &&
midResultFixture.contributorId &&
midResultFixture.woundedUnitId &&
midResultFixture.woundedHp < midResultFixture.woundedMaxHp,
`Expected a deterministic contribution and recovery fixture for the mid-campaign result: ${JSON.stringify(midResultFixture)}`
);
await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory'));
await waitForBattleOutcome(page, 'victory');
const midResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const midResultSaveBeforeUpdate = await readCampaignSave(page);
const midResultPerformance = midResultState?.sortieEvaluation?.snapshot;
const midContributorPerformance = midResultPerformance?.unitStats?.find((stats) => stats.unitId === midResultFixture.contributorId);
const midBattleRoleById = Object.fromEntries(
(midNextBattleProbe?.deployedAllyPositions ?? []).map((unit) => [unit.id, unit.formationRole])
);
const expectedMidResultPreset = {
selectedUnitIds: [...(midResultPerformance?.selectedUnitIds ?? [])],
formationAssignments: { ...(midResultPerformance?.formationAssignments ?? {}) }
};
assert(
midResultState?.sortieEvaluation?.available === true &&
midResultState.sortieEvaluation.open === false &&
midResultState.sortieEvaluation.totalDamage === midResultFixture.expectedStats.damageDealt &&
midResultState.sortieEvaluation.totalDamageTaken === midResultFixture.expectedStats.damageTaken &&
midResultState.sortieEvaluation.totalDefeats === midResultFixture.expectedStats.defeats &&
midResultState.sortieEvaluation.totalSupport === midResultFixture.expectedStats.support &&
midResultState.sortieEvaluation.recoveryNeededCount > 0 &&
midResultState.sortieEvaluation.roleCoverage < 3,
`Expected the mid-campaign evaluation to reflect the deterministic contribution, injury, and missing role: ${JSON.stringify(midResultState?.sortieEvaluation)}`
);
assert(
midContributorPerformance &&
Object.entries(midResultFixture.expectedStats).every(([key, value]) => midContributorPerformance[key] === value) &&
midContributorPerformance.hpBefore > 0 &&
midContributorPerformance.maxHpBefore >= midContributorPerformance.hpBefore &&
sameJsonValue([...(midResultPerformance?.selectedUnitIds ?? [])].sort(), [...midCampSelectedSortieUnitIds].sort()) &&
Object.keys(midResultPerformance?.formationAssignments ?? {}).length === midCampSelectedSortieUnitIds.length &&
midCampSelectedSortieUnitIds.every(
(unitId) => midResultPerformance?.formationAssignments?.[unitId] === midBattleRoleById[unitId]
),
`Expected the saved result snapshot to retain the exact roster, roles, starting HP, and contribution stats: ${JSON.stringify(midResultPerformance)}`
);
assert(
sameJsonValue(midResultSaveBeforeUpdate.current?.firstBattleReport?.sortiePerformance, midResultPerformance) &&
sameJsonValue(midResultSaveBeforeUpdate.slot1?.firstBattleReport?.sortiePerformance, midResultPerformance) &&
sameJsonValue(midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) &&
sameJsonValue(midResultSaveBeforeUpdate.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) &&
!sameJsonValue(midResultSaveBeforeUpdate.current?.sortieFormationPresets?.mobile, expectedMidResultPreset),
`Expected the mid result snapshot to synchronize everywhere while remaining meaningfully different from the saved mobile preset: ${JSON.stringify({
snapshot: midResultPerformance,
mobile: midResultSaveBeforeUpdate.current?.sortieFormationPresets?.mobile
})}`
);
const midEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle');
await page.mouse.click(midEvaluationTogglePoint.x, midEvaluationTogglePoint.y);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true, undefined, { timeout: 30000 });
const midEvaluationProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation;
return {
evaluation,
texts: (scene?.resultFormationReviewObjects ?? [])
.filter((object) => object?.type === 'Text')
.map((object) => object.text)
};
});
const midEvaluationSave = await readCampaignSave(page);
const mobileResultCard = midEvaluationProbe.evaluation?.presetCards?.find((card) => card.id === 'mobile');
assert(
midEvaluationProbe.texts.includes('이번 편성 평가') &&
midEvaluationProbe.texts.some((text) => text.includes('기동') && text.includes('갱신')) &&
mobileResultCard?.saved === true &&
mobileResultCard.matching === false &&
mobileResultCard.actionButtonBounds &&
sameJsonValue(midEvaluationSave, midResultSaveBeforeUpdate),
`Expected opening the evaluation to expose a non-destructive mobile preset update action: ${JSON.stringify(midEvaluationProbe)}`
);
const firstMobileUpdatePoint = await readBattleResultEvaluationControlPoint(page, 'preset', 'mobile');
await page.mouse.click(firstMobileUpdatePoint.x, firstMobileUpdatePoint.y);
await page.waitForFunction(() => {
const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation;
return evaluation?.open === true &&
evaluation?.overwriteConfirmId === 'mobile' &&
evaluation?.feedback?.includes('한 번 더');
}, undefined, { timeout: 30000 });
const pendingMobileUpdateState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const pendingMobileUpdateSave = await readCampaignSave(page);
assert(
pendingMobileUpdateState?.resultVisible === true &&
pendingMobileUpdateState?.sortieEvaluation?.sourcePresetIds?.includes('mobile') === false &&
sameJsonValue(pendingMobileUpdateSave, midResultSaveBeforeUpdate),
`Expected the first mobile update click to request confirmation without mutating either save: ${JSON.stringify({
evaluation: pendingMobileUpdateState?.sortieEvaluation,
save: pendingMobileUpdateSave
})}`
);
const confirmMobileUpdatePoint = await readBattleResultEvaluationControlPoint(page, 'preset', 'mobile');
await page.mouse.click(confirmMobileUpdatePoint.x, confirmMobileUpdatePoint.y);
await page.waitForFunction(() => {
const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation;
return evaluation?.open === true &&
evaluation?.overwriteConfirmId === null &&
evaluation?.sourcePresetIds?.includes('mobile') &&
evaluation?.feedback?.includes('갱신 완료');
}, undefined, { timeout: 30000 });
const updatedMobileResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const updatedMobileResultSave = await readCampaignSave(page);
const updatedCurrentMobilePreset = updatedMobileResultSave.current?.sortieFormationPresets?.mobile;
const updatedSlotMobilePreset = updatedMobileResultSave.slot1?.sortieFormationPresets?.mobile;
assert(
sameJsonValue(updatedCurrentMobilePreset, expectedMidResultPreset) &&
sameJsonValue(updatedSlotMobilePreset, expectedMidResultPreset) &&
JSON.stringify(Object.keys(updatedCurrentMobilePreset ?? {}).sort()) === JSON.stringify(['formationAssignments', 'selectedUnitIds']) &&
updatedCurrentMobilePreset?.sortieItemAssignments === undefined &&
updatedCurrentMobilePreset?.itemAssignments === undefined &&
updatedCurrentMobilePreset?.unitStats === undefined,
`Expected the confirmed result action to store only the evaluated roster and roles in current and slot-1 mobile presets: ${JSON.stringify(updatedMobileResultSave)}`
);
assert(
JSON.stringify(updatedMobileResultSave.current?.selectedSortieUnitIds) === JSON.stringify(midResultSaveBeforeUpdate.current?.selectedSortieUnitIds) &&
sameJsonValue(updatedMobileResultSave.current?.sortieFormationAssignments, midResultSaveBeforeUpdate.current?.sortieFormationAssignments) &&
sameJsonValue(updatedMobileResultSave.current?.sortieItemAssignments, midResultSaveBeforeUpdate.current?.sortieItemAssignments) &&
sameJsonValue(updatedMobileResultSave.slot1?.selectedSortieUnitIds, midResultSaveBeforeUpdate.slot1?.selectedSortieUnitIds) &&
sameJsonValue(updatedMobileResultSave.slot1?.sortieFormationAssignments, midResultSaveBeforeUpdate.slot1?.sortieFormationAssignments) &&
sameJsonValue(updatedMobileResultSave.slot1?.sortieItemAssignments, midResultSaveBeforeUpdate.slot1?.sortieItemAssignments) &&
sameJsonValue(updatedMobileResultSave.current?.firstBattleReport?.sortiePerformance, midResultPerformance) &&
sameJsonValue(updatedMobileResultSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) &&
sameJsonValue(updatedMobileResultSave.current?.sortieFormationPresets?.elite, midResultSaveBeforeUpdate.current?.sortieFormationPresets?.elite) &&
sameJsonValue(updatedMobileResultSave.current?.sortieFormationPresets?.siege, midResultSaveBeforeUpdate.current?.sortieFormationPresets?.siege),
`Expected result-to-preset update to leave the active sortie, supplies, report snapshot, and other presets unchanged: ${JSON.stringify({
before: midResultSaveBeforeUpdate,
after: updatedMobileResultSave
})}`
);
assert(
updatedMobileResultState?.sortieEvaluation?.presetCards?.find((card) => card.id === 'mobile')?.matching === true &&
updatedMobileResultState?.sortieEvaluation?.feedback?.includes('장비·보급은 저장하지 않았습니다.'),
`Expected visible completion feedback and a matching mobile result card after update: ${JSON.stringify(updatedMobileResultState?.sortieEvaluation)}`
);
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-formation-evaluation-updated.png`, fullPage: true });
await assertCanvasPainted(page, 'mid camp formation evaluation update');
await seedCampaignSave(page, {
battleId: 'sixty-sixth-battle-wuzhang-final',
battleTitle: '오장원 최종전',
@@ -1430,6 +1682,47 @@ async function readCampaignSave(page) {
});
}
async function readBattleResultEvaluationControlPoint(page, control, presetId) {
const probe = await page.evaluate(({ control, presetId }) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation;
const canvas = document.querySelector('canvas');
const presetCard = evaluation?.presetCards?.find((card) => card.id === presetId);
const bounds = control === 'toggle'
? evaluation?.toggleButtonBounds
: control === 'close'
? evaluation?.closeButtonBounds
: presetCard?.actionButtonBounds;
if (!scene || !canvas || !bounds) {
return {
ready: false,
control,
presetId: presetId ?? null,
evaluationOpen: evaluation?.open ?? false,
bounds: bounds ?? null
};
}
const canvasBounds = canvas.getBoundingClientRect();
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
ready: true,
control,
presetId: presetId ?? null,
evaluationOpen: evaluation?.open ?? false,
bounds,
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
};
}, { control, presetId });
assert(
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
`Expected a visible Phaser battle-result evaluation ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}`
);
return probe;
}
async function readSortieComparisonActionPoint(page) {
const probe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
@@ -1567,6 +1860,7 @@ async function seedCampaignSave(page, options) {
completedCampVisits: current.completedCampVisits ?? [],
createdAt: now
};
delete report.sortiePerformance;
const settlement = {
battleId: report.battleId,
battleTitle: report.battleTitle,