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

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