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;

View File

@@ -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
);
}

View File

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