feat: make sortie feedback actionable
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -35,6 +35,47 @@ try {
|
||||
throw new Error(`Expected first battle auto-save in current and slot 1 saves: ${JSON.stringify(autoSave)}`);
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
const battleId = 'first-battle-zhuo-commandery';
|
||||
const installRecommendation = (key) => {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const state = JSON.parse(raw);
|
||||
const orderId = state.sortieOrderSelection?.orderId ?? 'elite';
|
||||
state.sortieOrderSelection = { battleId, orderId };
|
||||
delete state.sortieResonanceSelection;
|
||||
const selectedUnitIds = state.selectedSortieUnitIds?.length
|
||||
? [...state.selectedSortieUnitIds]
|
||||
: ['liu-bei', 'guan-yu', 'zhang-fei'];
|
||||
state.selectedSortieUnitIds = [...selectedUnitIds];
|
||||
const fallbackRoles = ['front', 'flank', 'support', 'reserve'];
|
||||
const formationAssignments = Object.fromEntries(selectedUnitIds.map((unitId, index) => [
|
||||
unitId,
|
||||
state.sortieFormationAssignments?.[unitId] ?? fallbackRoles[index] ?? 'reserve'
|
||||
]));
|
||||
state.sortieFormationAssignments = {
|
||||
...(state.sortieFormationAssignments ?? {}),
|
||||
...formationAssignments
|
||||
};
|
||||
state.sortieRecommendationSelection = {
|
||||
version: 1,
|
||||
battleId,
|
||||
planId: 'order',
|
||||
label: '재도전 검증안',
|
||||
summary: '패배 후 같은 전장 보완 동선을 검증합니다.',
|
||||
sortieOrderId: orderId,
|
||||
selectedUnitIds,
|
||||
formationAssignments,
|
||||
unitReasons: Object.fromEntries(selectedUnitIds.map((unitId) => [unitId, '추천 역할 수행을 검증합니다.']))
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(state));
|
||||
};
|
||||
installRecommendation('heros-web:campaign-state');
|
||||
installRecommendation('heros-web:campaign-state:slot-1');
|
||||
});
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await waitForTitle(page);
|
||||
await page.screenshot({ path: 'dist/verification-save-title-continue.png', fullPage: true });
|
||||
@@ -42,7 +83,12 @@ try {
|
||||
await startTitleDefaultAction(page);
|
||||
await waitForBattleReady(page, 'first-battle-zhuo-commandery');
|
||||
const continuedBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||
if (continuedBattle?.battleOutcome !== null || continuedBattle?.resultVisible !== false || continuedBattle?.turnNumber !== 1) {
|
||||
if (
|
||||
continuedBattle?.battleOutcome !== null ||
|
||||
continuedBattle?.resultVisible !== false ||
|
||||
continuedBattle?.turnNumber !== 1 ||
|
||||
continuedBattle?.sortieRecommendation?.battleId !== 'first-battle-zhuo-commandery'
|
||||
) {
|
||||
throw new Error(`Expected continue to reopen a clean first battle: ${JSON.stringify(continuedBattle)}`);
|
||||
}
|
||||
await page.screenshot({ path: 'dist/verification-save-continued-battle.png', fullPage: true });
|
||||
@@ -56,7 +102,86 @@ try {
|
||||
throw new Error(`Expected defeat to save retry-ready first battle state: ${JSON.stringify(defeatSave.current)}`);
|
||||
}
|
||||
|
||||
await clickLegacyUi(page, 884, 642);
|
||||
const closedDefeatEvaluation = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation);
|
||||
if (
|
||||
closedDefeatEvaluation?.improvementAction?.available !== true ||
|
||||
closedDefeatEvaluation.improvementAction.label !== '재도전 보완' ||
|
||||
closedDefeatEvaluation.improvementAction.sourceBattleId !== 'first-battle-zhuo-commandery' ||
|
||||
closedDefeatEvaluation.improvementAction.targetBattleId !== 'first-battle-zhuo-commandery'
|
||||
) {
|
||||
throw new Error(`Expected defeat to expose same-battle improvement semantics: ${JSON.stringify(closedDefeatEvaluation)}`);
|
||||
}
|
||||
await clickDebugBounds(page, 'BattleScene', closedDefeatEvaluation.toggleButtonBounds);
|
||||
await page.waitForFunction(
|
||||
() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true,
|
||||
undefined,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
const openedDefeatEvaluation = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation);
|
||||
if (!insideViewport(openedDefeatEvaluation?.improvementAction?.buttonBounds, baselineViewport)) {
|
||||
throw new Error(`Expected an in-FHD defeat improvement button: ${JSON.stringify(openedDefeatEvaluation)}`);
|
||||
}
|
||||
await clickDebugBounds(page, 'BattleScene', openedDefeatEvaluation.improvementAction.buttonBounds);
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const camp = window.__HEROS_DEBUG__?.camp?.();
|
||||
return camp?.sortieVisible === true &&
|
||||
camp?.sortiePrepStep === 'formation' &&
|
||||
camp?.nextSortieBattleId === 'first-battle-zhuo-commandery' &&
|
||||
camp?.sortieComparisonAction?.kind === 'confirm-improvement';
|
||||
}, undefined, { timeout: 30000 });
|
||||
} catch {
|
||||
const transitionState = await page.evaluate(() => ({
|
||||
activeScenes: window.__HEROS_GAME__?.scene.getScenes(true).map((scene) => scene.scene.key),
|
||||
battleOutcome: window.__HEROS_DEBUG__?.battle?.()?.battleOutcome,
|
||||
camp: (() => {
|
||||
const camp = window.__HEROS_DEBUG__?.camp?.();
|
||||
return camp
|
||||
? {
|
||||
sortieVisible: camp.sortieVisible,
|
||||
sortiePrepStep: camp.sortiePrepStep,
|
||||
nextSortieBattleId: camp.nextSortieBattleId,
|
||||
selectedSortieUnitIds: camp.campaign?.selectedSortieUnitIds,
|
||||
guidedImprovement: camp.sortieGuidedImprovement,
|
||||
comparisonAction: camp.sortieComparisonAction,
|
||||
feedback: camp.sortiePlanFeedback
|
||||
}
|
||||
: null;
|
||||
})()
|
||||
}));
|
||||
throw new Error(`Expected defeat improvement CTA to open same-battle Camp preview: ${JSON.stringify(transitionState)}`);
|
||||
}
|
||||
const defeatGuidedPreview = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
if (
|
||||
defeatGuidedPreview?.sortieGuidedImprovement?.sourceBattleId !== 'first-battle-zhuo-commandery' ||
|
||||
defeatGuidedPreview.sortieGuidedImprovement.targetBattleId !== 'first-battle-zhuo-commandery' ||
|
||||
defeatGuidedPreview.sortieComparisonPreview?.source !== 'guided-improvement' ||
|
||||
!insideViewport(defeatGuidedPreview.sortieComparisonAction?.buttonBounds, baselineViewport)
|
||||
) {
|
||||
throw new Error(`Expected same-battle guided preview after defeat: ${JSON.stringify(defeatGuidedPreview)}`);
|
||||
}
|
||||
await clickDebugBounds(page, 'CampScene', defeatGuidedPreview.sortieComparisonAction.buttonBounds);
|
||||
await page.waitForFunction(
|
||||
() => window.__HEROS_DEBUG__?.camp?.()?.sortieComparisonAction?.kind === 'undo-improvement',
|
||||
undefined,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
const appliedDefeatImprovement = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
|
||||
const appliedDefeatProposal = appliedDefeatImprovement?.sortieGuidedImprovement?.proposal;
|
||||
if (
|
||||
!appliedDefeatProposal ||
|
||||
appliedDefeatImprovement?.sortieRecommendationSelection?.battleId !== 'first-battle-zhuo-commandery' ||
|
||||
appliedDefeatImprovement.sortieRecommendationSelection.planId !== appliedDefeatProposal.planId
|
||||
) {
|
||||
throw new Error(`Expected defeat improvement to preserve same-battle recommendation provenance: ${JSON.stringify(appliedDefeatImprovement)}`);
|
||||
}
|
||||
await clickLegacyUi(page, 1116, 656);
|
||||
await page.waitForFunction(
|
||||
() => window.__HEROS_DEBUG__?.camp?.()?.sortiePrepStep === 'loadout',
|
||||
undefined,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await clickLegacyUi(page, 1116, 656);
|
||||
await waitForBattleReady(page, 'first-battle-zhuo-commandery');
|
||||
const retriedBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||||
const retriedLiuBei = retriedBattle?.units?.find((unit) => unit.id === 'liu-bei');
|
||||
@@ -66,6 +191,9 @@ try {
|
||||
retriedBattle?.battleOutcome !== null ||
|
||||
retriedBattle?.resultVisible !== false ||
|
||||
retriedBattle?.turnNumber !== 1 ||
|
||||
retriedBattle?.sortieRecommendation?.battleId !== 'first-battle-zhuo-commandery' ||
|
||||
retriedBattle?.sortieRecommendation?.planId !== appliedDefeatProposal.planId ||
|
||||
JSON.stringify(retriedBattle?.selectedSortieUnitIds) !== JSON.stringify(appliedDefeatProposal.projectedSelectedUnitIds) ||
|
||||
!retriedLiuBei ||
|
||||
retriedLiuBei.hp !== retriedLiuBei.maxHp ||
|
||||
damagedAlliesAfterRetry.length > 0 ||
|
||||
@@ -275,3 +403,43 @@ function delay(ms) {
|
||||
async function clickLegacyUi(page, x, y, options) {
|
||||
await page.mouse.click(x * legacyUiScale, y * legacyUiScale, options);
|
||||
}
|
||||
|
||||
async function clickDebugBounds(page, sceneKey, bounds) {
|
||||
if (!bounds) {
|
||||
throw new Error(`Expected ${sceneKey} debug bounds before click.`);
|
||||
}
|
||||
const point = await page.evaluate(({ sceneKey, bounds }) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene(sceneKey);
|
||||
const canvas = document.querySelector('canvas');
|
||||
if (!scene || !canvas) {
|
||||
return null;
|
||||
}
|
||||
const canvasBounds = canvas.getBoundingClientRect();
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
return {
|
||||
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
|
||||
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
|
||||
};
|
||||
}, { sceneKey, bounds });
|
||||
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) {
|
||||
throw new Error(`Expected a canvas-scaled ${sceneKey} click point: ${JSON.stringify({ bounds, point })}`);
|
||||
}
|
||||
await page.mouse.click(point.x, point.y);
|
||||
}
|
||||
|
||||
function insideViewport(bounds, viewport) {
|
||||
return Boolean(
|
||||
bounds &&
|
||||
Number.isFinite(bounds.x) &&
|
||||
Number.isFinite(bounds.y) &&
|
||||
Number.isFinite(bounds.width) &&
|
||||
Number.isFinite(bounds.height) &&
|
||||
bounds.width > 0 &&
|
||||
bounds.height > 0 &&
|
||||
bounds.x >= 0 &&
|
||||
bounds.y >= 0 &&
|
||||
bounds.x + bounds.width <= viewport.width &&
|
||||
bounds.y + bounds.height <= viewport.height
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ try {
|
||||
sortieRecommendationContributionCompactLabel,
|
||||
sortieRecommendationDisplayUnitIds
|
||||
} = await server.ssrLoadModule('/src/game/data/sortieRecommendationOutcome.ts');
|
||||
const { buildSortieRecommendationImprovement } = await server.ssrLoadModule('/src/game/data/sortieRecommendationImprovement.ts');
|
||||
const { getUnitStrategyCoverage, usableCatalog } = await server.ssrLoadModule('/src/game/data/battleUsables.ts');
|
||||
const signatureStrategyByOwner = new Map(
|
||||
Object.values(usableCatalog)
|
||||
@@ -117,6 +118,7 @@ try {
|
||||
verifyRequiredOverflow(fixtureContext, buildSortieRecommendationPlans);
|
||||
verifyDuplicateAndNonAllyCandidates(fixtureContext, buildSortieRecommendationPlans, formationRoles);
|
||||
verifyMissingOrderFallback(fixtureContext, buildSortieRecommendationPlans, formationRoles);
|
||||
verifyRecommendationImprovement(buildSortieRecommendationImprovement);
|
||||
verifyRecommendationOutcomeFeedback(
|
||||
evaluateSortieRecommendationOutcome,
|
||||
sortieRecommendationContributionCompactLabel,
|
||||
@@ -449,6 +451,162 @@ function verifyMissingOrderFallback(base, buildPlans, formationRoles) {
|
||||
);
|
||||
}
|
||||
|
||||
function verifyRecommendationImprovement(buildImprovement) {
|
||||
const names = {
|
||||
'liu-bei': '유비',
|
||||
'guan-yu': '관우',
|
||||
'zhao-yun': '조운',
|
||||
'zhuge-liang': '제갈량'
|
||||
};
|
||||
const targetPlan = {
|
||||
id: 'resonance',
|
||||
label: '공명·추격형',
|
||||
score: 92,
|
||||
selectedUnitIds: ['liu-bei', 'zhao-yun', 'zhuge-liang'],
|
||||
formationAssignments: {
|
||||
'liu-bei': 'front',
|
||||
'zhao-yun': 'flank',
|
||||
'zhuge-liang': 'support'
|
||||
},
|
||||
unitReasons: {
|
||||
'liu-bei': '전열을 지킵니다.',
|
||||
'zhao-yun': '공명 추격을 여는 돌파 역할입니다.',
|
||||
'zhuge-liang': '후원 전법을 담당합니다.'
|
||||
}
|
||||
};
|
||||
const swapInput = {
|
||||
plan: targetPlan,
|
||||
selectedUnitIds: ['liu-bei', 'guan-yu', 'zhuge-liang'],
|
||||
formationAssignments: {
|
||||
'liu-bei': 'front',
|
||||
'guan-yu': 'flank',
|
||||
'zhuge-liang': 'support'
|
||||
},
|
||||
requiredUnitIds: ['liu-bei'],
|
||||
unitNames: names,
|
||||
previousContributions: [
|
||||
{ unitId: 'guan-yu', status: 'miss', score: 18, deployed: true, headline: '추천 역할 미달', reason: '돌파 부족' }
|
||||
]
|
||||
};
|
||||
const swap = deterministicImprovement(buildImprovement, swapInput, 'swap fixture');
|
||||
assert(swap.kind === 'swap', `Expected one-unit swap improvement: ${JSON.stringify(swap)}`);
|
||||
assert(
|
||||
swap.outgoingUnitId === 'guan-yu' && swap.incomingUnitId === 'zhao-yun',
|
||||
`Swap must remove the weakest optional officer and add the target officer: ${JSON.stringify(swap)}`
|
||||
);
|
||||
assert(swap.projectedSelectedUnitIds.length === swap.baselineSelectedUnitIds.length, 'Swap must retain sortie size.');
|
||||
assert(
|
||||
symmetricDifference(swap.baselineSelectedUnitIds, swap.projectedSelectedUnitIds).length === 2,
|
||||
`Swap must have exactly one OUT and one IN: ${JSON.stringify(swap)}`
|
||||
);
|
||||
assert(swap.projectedSelectedUnitIds.includes('liu-bei'), 'Swap must preserve required officers.');
|
||||
assert(
|
||||
swap.projectedFormationAssignments['guan-yu'] === undefined &&
|
||||
swap.projectedFormationAssignments['zhao-yun'] === 'flank' &&
|
||||
swap.projectedFormationAssignments['liu-bei'] === 'front' &&
|
||||
swap.projectedFormationAssignments['zhuge-liang'] === 'support',
|
||||
`Swap must only replace the outgoing assignment with the target role: ${JSON.stringify(swap)}`
|
||||
);
|
||||
|
||||
const addInput = {
|
||||
plan: targetPlan,
|
||||
selectedUnitIds: ['liu-bei', 'zhuge-liang'],
|
||||
formationAssignments: {
|
||||
'liu-bei': 'front',
|
||||
'zhuge-liang': 'support'
|
||||
},
|
||||
requiredUnitIds: ['liu-bei'],
|
||||
unitNames: names
|
||||
};
|
||||
const add = deterministicImprovement(buildImprovement, addInput, 'open-slot fixture');
|
||||
assert(add.kind === 'add', `Expected one-officer addition for an open slot: ${JSON.stringify(add)}`);
|
||||
assert(
|
||||
add.incomingUnitId === 'zhao-yun' &&
|
||||
symmetricDifference(add.baselineSelectedUnitIds, add.projectedSelectedUnitIds).length === 1 &&
|
||||
add.projectedFormationAssignments['zhao-yun'] === 'flank',
|
||||
`Open-slot improvement must fill exactly one target officer and role: ${JSON.stringify(add)}`
|
||||
);
|
||||
|
||||
const roleInput = {
|
||||
plan: targetPlan,
|
||||
selectedUnitIds: [...targetPlan.selectedUnitIds],
|
||||
formationAssignments: {
|
||||
...targetPlan.formationAssignments,
|
||||
'zhao-yun': 'support'
|
||||
},
|
||||
requiredUnitIds: ['liu-bei'],
|
||||
unitNames: names,
|
||||
previousContributions: [
|
||||
{ unitId: 'zhao-yun', status: 'partial', score: 46, deployed: true, headline: '역할 부분 달성', reason: '돌파 부족' }
|
||||
]
|
||||
};
|
||||
const role = deterministicImprovement(buildImprovement, roleInput, 'role fixture');
|
||||
assert(role.kind === 'role', `Expected one-role improvement: ${JSON.stringify(role)}`);
|
||||
assert(
|
||||
JSON.stringify(role.projectedSelectedUnitIds) === JSON.stringify(role.baselineSelectedUnitIds),
|
||||
`Role improvement must not change the roster: ${JSON.stringify(role)}`
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(changedAssignmentKeys(role.baselineFormationAssignments, role.projectedFormationAssignments)) === JSON.stringify(['zhao-yun']) &&
|
||||
role.fromRole === 'support' && role.toRole === 'flank',
|
||||
`Role improvement must change exactly one officer from support to flank: ${JSON.stringify(role)}`
|
||||
);
|
||||
|
||||
const tacticInput = {
|
||||
plan: targetPlan,
|
||||
selectedUnitIds: [...targetPlan.selectedUnitIds],
|
||||
formationAssignments: { ...targetPlan.formationAssignments },
|
||||
requiredUnitIds: ['liu-bei'],
|
||||
unitNames: names,
|
||||
previousContributions: [
|
||||
{ unitId: 'zhao-yun', status: 'partial', score: 30, deployed: true, headline: '공명 추격 미발동', reason: '추격 기회 부족' }
|
||||
]
|
||||
};
|
||||
const tactic = deterministicImprovement(buildImprovement, tacticInput, 'tactic fixture');
|
||||
assert(tactic.kind === 'tactic', `Expected tactical fallback when roster and roles already match: ${JSON.stringify(tactic)}`);
|
||||
assert(
|
||||
JSON.stringify(tactic.projectedSelectedUnitIds) === JSON.stringify(tactic.baselineSelectedUnitIds) &&
|
||||
JSON.stringify(tactic.projectedFormationAssignments) === JSON.stringify(tactic.baselineFormationAssignments) &&
|
||||
tactic.instruction.includes('추격 1회'),
|
||||
`Tactical fallback must preserve configuration and give one concrete pursuit action: ${JSON.stringify(tactic)}`
|
||||
);
|
||||
|
||||
const requiredGuardInput = {
|
||||
plan: {
|
||||
...targetPlan,
|
||||
selectedUnitIds: ['liu-bei', 'zhao-yun'],
|
||||
formationAssignments: { 'liu-bei': 'front', 'zhao-yun': 'flank' }
|
||||
},
|
||||
selectedUnitIds: ['liu-bei', 'guan-yu'],
|
||||
formationAssignments: { 'liu-bei': 'front', 'guan-yu': 'flank' },
|
||||
requiredUnitIds: ['liu-bei', 'guan-yu'],
|
||||
unitNames: names
|
||||
};
|
||||
const requiredGuard = deterministicImprovement(buildImprovement, requiredGuardInput, 'required guard fixture');
|
||||
assert(requiredGuard.kind !== 'swap', `Required officers must never be selected as swap OUT: ${JSON.stringify(requiredGuard)}`);
|
||||
}
|
||||
|
||||
function deterministicImprovement(buildImprovement, input, label) {
|
||||
const frozenInput = JSON.stringify(input);
|
||||
const result = buildImprovement(input);
|
||||
const repeated = buildImprovement(input);
|
||||
assert(JSON.stringify(result) === JSON.stringify(repeated), `${label}: improvement must be deterministic`);
|
||||
assert(JSON.stringify(input) === frozenInput, `${label}: improvement must not mutate its input`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function symmetricDifference(left, right) {
|
||||
const leftSet = new Set(left);
|
||||
const rightSet = new Set(right);
|
||||
return [...new Set([...left, ...right])].filter((value) => leftSet.has(value) !== rightSet.has(value));
|
||||
}
|
||||
|
||||
function changedAssignmentKeys(left, right) {
|
||||
return [...new Set([...Object.keys(left), ...Object.keys(right)])]
|
||||
.filter((key) => left[key] !== right[key])
|
||||
.sort();
|
||||
}
|
||||
|
||||
function verifyRecommendationOutcomeFeedback(evaluateOutcome, compactContribution, displayUnitIds) {
|
||||
const selectedUnitIds = ['fixture-front', 'fixture-flank', 'fixture-support'];
|
||||
const formationAssignments = {
|
||||
|
||||
Reference in New Issue
Block a user