feat: make sortie feedback actionable
This commit is contained in:
@@ -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