feat: make sortie feedback actionable

This commit is contained in:
2026-07-15 06:30:26 +09:00
parent 0cadcb22b9
commit b96029bce3
6 changed files with 1506 additions and 38 deletions

View File

@@ -5361,15 +5361,110 @@ try {
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-formation-evaluation-updated.png`, fullPage: true });
await assertCanvasPainted(page, 'mid camp formation evaluation update');
const closeMidBattleEvaluationPoint = await readBattleResultEvaluationControlPoint(page, 'close');
await page.mouse.click(closeMidBattleEvaluationPoint.x, closeMidBattleEvaluationPoint.y);
const midResultImprovementAction = updatedMobileResultState?.sortieEvaluation?.improvementAction;
const midImprovementTargetBattleId = midResultImprovementAction?.targetBattleId;
assert(
midResultImprovementAction?.available === true &&
midResultImprovementAction.label === '다음 출전 보완' &&
midResultImprovementAction.sourceBattleId === midCampNextBattleId &&
typeof midImprovementTargetBattleId === 'string' &&
midImprovementTargetBattleId !== midCampNextBattleId &&
midResultImprovementAction.planId === midRecommendationLaunchFixture.selection.planId &&
isFiniteBounds(midResultImprovementAction.buttonBounds) &&
boundsInside(midResultImprovementAction.buttonBounds, fhdViewportBounds),
`Expected the victory evaluation to expose an in-FHD next-sortie improvement action: ${JSON.stringify(midResultImprovementAction)}`
);
const midImprovementPoint = await readBattleResultEvaluationControlPoint(page, 'improvement');
await page.mouse.click(midImprovementPoint.x, midImprovementPoint.y);
await waitForSortiePrep(page);
const guidedPreviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const guidedPreviewSave = await readCampaignSave(page);
const guidedPreview = guidedPreviewState?.sortieGuidedImprovement;
assert(
guidedPreviewState?.sortiePrepStep === 'formation' &&
guidedPreviewState.nextSortieBattleId === midImprovementTargetBattleId &&
guidedPreview?.stage === 'preview' &&
guidedPreview.sourceBattleId === midCampNextBattleId &&
guidedPreview.targetBattleId === midImprovementTargetBattleId &&
guidedPreview.proposal?.planId === midResultImprovementAction.planId &&
guidedImprovementHasOneAction(guidedPreview.proposal) &&
guidedPreviewState.sortieComparisonPreview?.source === 'guided-improvement' &&
guidedPreviewState.sortieComparisonAction?.kind === 'confirm-improvement' &&
isFiniteBounds(guidedPreviewState.sortieComparisonAction?.buttonBounds) &&
boundsInside(guidedPreviewState.sortieComparisonAction.buttonBounds, fhdViewportBounds) &&
sameJsonValue(
campaignSaveWithoutCampRecruitmentEffects(guidedPreviewSave),
campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave)
),
`Expected a non-destructive one-officer improvement preview for the next battle: ${JSON.stringify({
prepStep: guidedPreviewState?.sortiePrepStep,
nextBattleId: guidedPreviewState?.nextSortieBattleId,
guidedImprovement: guidedPreview,
comparisonPreviewSource: guidedPreviewState?.sortieComparisonPreview?.source,
comparisonAction: guidedPreviewState?.sortieComparisonAction,
stableSaveUnchanged: sameJsonValue(
campaignSaveWithoutCampRecruitmentEffects(guidedPreviewSave),
campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave)
)
})}`
);
await page.screenshot({ path: `${screenshotDir}/rc-mid-sortie-guided-improvement-preview.png`, fullPage: true });
await assertCanvasPainted(page, 'mid sortie guided improvement preview');
const guidedApplyPoint = await readSortieComparisonActionPoint(page);
await page.mouse.click(guidedApplyPoint.x, guidedApplyPoint.y);
await page.waitForFunction(() => {
const camp = window.__HEROS_DEBUG__?.camp?.();
return camp?.sortieGuidedImprovement?.stage === 'applied' &&
camp?.sortieComparisonAction?.kind === 'undo-improvement';
}, undefined, { timeout: 30000 });
const guidedAppliedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const guidedAppliedSave = await readCampaignSave(page);
const guidedProposal = guidedAppliedState?.sortieGuidedImprovement?.proposal;
assert(
sameJsonValue(guidedAppliedState?.selectedSortieUnitIds, guidedProposal?.projectedSelectedUnitIds) &&
selectedAssignmentsMatch(guidedAppliedState?.sortieFormationAssignments, guidedProposal?.projectedFormationAssignments, guidedProposal?.projectedSelectedUnitIds) &&
sameJsonValue(guidedAppliedSave.current?.selectedSortieUnitIds, guidedProposal?.projectedSelectedUnitIds) &&
selectedAssignmentsMatch(guidedAppliedSave.current?.sortieFormationAssignments, guidedProposal?.projectedFormationAssignments, guidedProposal?.projectedSelectedUnitIds) &&
guidedAppliedSave.current?.sortieRecommendationSelection?.battleId === midImprovementTargetBattleId &&
guidedAppliedSave.current?.sortieRecommendationSelection?.planId === midResultImprovementAction.planId &&
guidedAppliedState?.sortieGuidedImprovementUndo?.available === true,
`Expected confirmation to persist exactly the projected roster/role change and revised recommendation provenance: ${JSON.stringify({
state: guidedAppliedState,
save: guidedAppliedSave
})}`
);
const guidedUndoPoint = await readSortieComparisonActionPoint(page);
await page.mouse.click(guidedUndoPoint.x, guidedUndoPoint.y);
await page.waitForFunction((baselineIds) => {
const camp = window.__HEROS_DEBUG__?.camp?.();
return camp?.sortieGuidedImprovement === null &&
camp?.sortieGuidedImprovementUndo === null &&
JSON.stringify(camp?.selectedSortieUnitIds) === JSON.stringify(baselineIds);
}, guidedProposal.baselineSelectedUnitIds, { timeout: 30000 });
const guidedUndoState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const guidedUndoSave = await readCampaignSave(page);
assert(
sameJsonValue(guidedUndoState?.selectedSortieUnitIds, guidedProposal.baselineSelectedUnitIds) &&
selectedAssignmentsMatch(guidedUndoState?.sortieFormationAssignments, guidedProposal.baselineFormationAssignments, guidedProposal.baselineSelectedUnitIds) &&
sameJsonValue(guidedUndoSave.current?.selectedSortieUnitIds, guidedProposal.baselineSelectedUnitIds) &&
selectedAssignmentsMatch(guidedUndoSave.current?.sortieFormationAssignments, guidedProposal.baselineFormationAssignments, guidedProposal.baselineSelectedUnitIds) &&
guidedUndoSave.current?.sortieRecommendationSelection === undefined,
`Expected one-click undo to restore the exact pre-improvement formation: ${JSON.stringify({
state: guidedUndoState,
save: guidedUndoSave
})}`
);
const returnFromGuidedImprovementPoint = await readSortieTextControlPoint(page, '군영으로');
await page.mouse.click(returnFromGuidedImprovementPoint.x, returnFromGuidedImprovementPoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false,
() => window.__HEROS_DEBUG__?.camp?.()?.sortieVisible === false,
undefined,
{ timeout: 30000 }
);
await clickLegacyUi(page, 738, 642);
await waitForCampAfterBattleResult(page);
const midCampReviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const midCampReviewSaveBefore = await readCampaignSave(page);
@@ -5387,6 +5482,11 @@ try {
recommendationFeedbackSemantic(midCampFormationEvaluation.recommendationFeedback),
recommendationFeedbackSemantic(updatedMobileResultState?.sortieEvaluation?.recommendationFeedback)
) &&
midCampFormationEvaluation.improvementAction?.available === true &&
midCampFormationEvaluation.improvementAction.label === '다음 출전 보완' &&
midCampFormationEvaluation.improvementAction.sourceBattleId === midCampNextBattleId &&
midCampFormationEvaluation.improvementAction.targetBattleId === midImprovementTargetBattleId &&
midCampFormationEvaluation.improvementAction.planId === midResultImprovementAction.planId &&
sameJsonValue(midCampFormationEvaluation.sourcePresetIds, midResultSortieReviewBeforePresetUpdate.sourcePresetIds) &&
midCampMobileCard?.saved === true &&
midCampMobileCard.matching === true &&
@@ -5425,6 +5525,9 @@ try {
openedMidCampReviewProbe.state?.reportFormationEvaluation?.open === true &&
openedMidCampReviewProbe.texts.includes('최근 전투 편성 평가') &&
openedMidCampReviewProbe.texts.some((text) => text.includes('추천안 검증') && text.includes(midRecommendationLaunchFixture.selection.label)) &&
openedMidCampReviewProbe.texts.includes('다음 출전 보완') &&
isFiniteBounds(openedMidCampReviewProbe.state?.reportFormationEvaluation?.improvementAction?.buttonBounds) &&
boundsInside(openedMidCampReviewProbe.state.reportFormationEvaluation.improvementAction.buttonBounds, fhdViewportBounds) &&
isFiniteBounds(openedCampRecommendationFeedback?.panelBounds) &&
boundsInside(openedCampRecommendationFeedback.panelBounds, fhdViewportBounds) &&
sameJsonValue(openedCampRecommendationFeedback.displayedUnitIds, midRecommendationLaunchFixture.selection.selectedUnitIds) &&
@@ -8122,7 +8225,9 @@ async function readBattleResultEvaluationControlPoint(page, control, presetId) {
? evaluation?.toggleButtonBounds
: control === 'close'
? evaluation?.closeButtonBounds
: presetCard?.actionButtonBounds;
: control === 'improvement'
? evaluation?.improvementAction?.buttonBounds
: presetCard?.actionButtonBounds;
if (!scene || !canvas || !bounds) {
return {
ready: false,
@@ -8190,7 +8295,9 @@ async function readCampReportFormationEvaluationControlPoint(page, control, pres
? evaluation?.toggleButtonBounds
: control === 'close'
? evaluation?.closeButtonBounds
: presetCard?.actionButtonBounds;
: control === 'improvement'
? evaluation?.improvementAction?.buttonBounds
: presetCard?.actionButtonBounds;
if (!scene || !canvas || !bounds) {
return {
ready: false,
@@ -8962,10 +9069,86 @@ function assertSameMembers(values, expected, message) {
assert(expected.every((value) => valueSet.has(value)), message);
}
function selectedAssignmentsMatch(actual, expected, selectedUnitIds) {
return Boolean(
actual && expected && Array.isArray(selectedUnitIds) &&
selectedUnitIds.every((unitId) => actual[unitId] === expected[unitId])
);
}
function guidedImprovementHasOneAction(proposal) {
if (!proposal) {
return false;
}
const baselineIds = proposal.baselineSelectedUnitIds ?? [];
const projectedIds = proposal.projectedSelectedUnitIds ?? [];
const idSet = new Set([...baselineIds, ...projectedIds]);
const rosterDelta = [...idSet].filter(
(unitId) => baselineIds.includes(unitId) !== projectedIds.includes(unitId)
);
const roleDelta = [...idSet].filter(
(unitId) => proposal.baselineFormationAssignments?.[unitId] !== proposal.projectedFormationAssignments?.[unitId]
);
if (proposal.kind === 'swap') {
return baselineIds.length === projectedIds.length &&
rosterDelta.length === 2 &&
!projectedIds.includes(proposal.outgoingUnitId) &&
projectedIds.includes(proposal.incomingUnitId);
}
if (proposal.kind === 'add') {
return projectedIds.length === baselineIds.length + 1 &&
rosterDelta.length === 1 &&
rosterDelta[0] === proposal.incomingUnitId &&
projectedIds.includes(proposal.incomingUnitId);
}
if (proposal.kind === 'role') {
return sameJsonValue(baselineIds, projectedIds) &&
roleDelta.length === 1 &&
roleDelta[0] === proposal.unitId;
}
return proposal.kind === 'tactic' &&
sameJsonValue(baselineIds, projectedIds) &&
roleDelta.length === 0 &&
typeof proposal.instruction === 'string' &&
proposal.instruction.length > 0;
}
function sameJsonValue(value, expected) {
return stableJson(value) === stableJson(expected);
}
function campaignSaveWithoutCampRecruitmentEffects(save) {
const stableState = (state) => {
if (!state) {
return state;
}
const {
updatedAt: _updatedAt,
roster: _roster,
bonds: _bonds,
firstBattleReport,
...stableFields
} = state;
if (!firstBattleReport) {
return stableFields;
}
const {
units: _reportUnits,
bonds: _reportBonds,
...stableReportFields
} = firstBattleReport;
return {
...stableFields,
firstBattleReport: stableReportFields
};
};
return {
current: stableState(save?.current),
slot1: stableState(save?.slot1)
};
}
function recommendationFeedbackSemantic(feedback) {
if (!feedback) {
return null;