feat: connect sortie recommendations to battle results

This commit is contained in:
2026-07-13 09:50:36 +09:00
parent 7b58317f67
commit 0cadcb22b9
9 changed files with 1958 additions and 104 deletions

View File

@@ -15,6 +15,11 @@ try {
} = await server.ssrLoadModule('/src/game/data/sortieRecommendations.ts');
const { sortieFormationRoles } = await server.ssrLoadModule('/src/game/data/sortieDeployment.ts');
const { sortieOrderIds } = await server.ssrLoadModule('/src/game/data/sortieOrders.ts');
const {
evaluateSortieRecommendationOutcome,
sortieRecommendationContributionCompactLabel,
sortieRecommendationDisplayUnitIds
} = await server.ssrLoadModule('/src/game/data/sortieRecommendationOutcome.ts');
const { getUnitStrategyCoverage, usableCatalog } = await server.ssrLoadModule('/src/game/data/battleUsables.ts');
const signatureStrategyByOwner = new Map(
Object.values(usableCatalog)
@@ -112,6 +117,11 @@ try {
verifyRequiredOverflow(fixtureContext, buildSortieRecommendationPlans);
verifyDuplicateAndNonAllyCandidates(fixtureContext, buildSortieRecommendationPlans, formationRoles);
verifyMissingOrderFallback(fixtureContext, buildSortieRecommendationPlans, formationRoles);
verifyRecommendationOutcomeFeedback(
evaluateSortieRecommendationOutcome,
sortieRecommendationContributionCompactLabel,
sortieRecommendationDisplayUnitIds
);
console.log(
`Verified ${evaluatedPlanCount} recommendation plans across ${scenarioEntries.length} battles and ${sortieOrderIds.length} orders; ` +
@@ -439,6 +449,233 @@ function verifyMissingOrderFallback(base, buildPlans, formationRoles) {
);
}
function verifyRecommendationOutcomeFeedback(evaluateOutcome, compactContribution, displayUnitIds) {
const selectedUnitIds = ['fixture-front', 'fixture-flank', 'fixture-support'];
const formationAssignments = {
'fixture-front': 'front',
'fixture-flank': 'flank',
'fixture-support': 'support'
};
const unitReasons = {
'fixture-front': '전열 방호를 맡습니다.',
'fixture-flank': '측면 돌파를 맡습니다.',
'fixture-support': '고유 시험 전법으로 후원을 맡습니다.'
};
const performance = {
selectedUnitIds: [...selectedUnitIds],
formationAssignments: { ...formationAssignments },
unitStats: [
outcomeStats('fixture-front', { actions: 2, damageTaken: 8, sortiePreventedDamage: 8, intentGuardSuccesses: 1 }),
outcomeStats('fixture-flank', { actions: 2, damageDealt: 18, defeats: 1, sortieBonusDamage: 6, intentLineBreaks: 1 }),
outcomeStats('fixture-support', { actions: 2, support: 6, sortieBonusHealing: 5, sortieExtendedBondTriggers: 1, strategyUses: 1, signatureStrategyUses: 1 })
]
};
for (const planId of ['order', 'resonance', 'terrain-strategy']) {
const input = {
planId,
selectedUnitIds,
formationAssignments,
unitReasons,
performance,
outcome: 'victory',
objectives: [{ achieved: true }, { achieved: true }],
activeBondCount: 1
};
const frozenInput = JSON.stringify(input);
const outcome = evaluateOutcome(input);
assert(outcome.status === 'hit', `${planId}: expected the strong fixture to hit: ${JSON.stringify(outcome)}`);
assert(outcome.kpis.length === 3, `${planId}: expected execution, battle, and focus KPI rows`);
assert(
JSON.stringify(outcome.unitContributions.map((entry) => entry.unitId)) === JSON.stringify(selectedUnitIds),
`${planId}: unit feedback must retain recommendation order: ${JSON.stringify(outcome.unitContributions)}`
);
assert(
outcome.unitContributions.every((entry) => entry.reason === unitReasons[entry.unitId]),
`${planId}: unit feedback must retain the applied recommendation reasons`
);
assert(JSON.stringify(input) === frozenInput, `${planId}: outcome evaluation must not mutate its input`);
}
const failedOrder = evaluateOutcome({
planId: 'order',
selectedUnitIds,
formationAssignments,
unitReasons,
performance,
outcome: 'victory',
objectives: [{ achieved: true }],
activeBondCount: 1,
sortieOrder: { achieved: false, score: 42 }
});
const failedOrderFocus = failedOrder.kpis.find((entry) => entry.id === 'focus');
assert(
failedOrderFocus?.status === 'miss' && failedOrderFocus.score === 42 && failedOrderFocus.value.includes('미달'),
`Order recommendation focus must follow the actual order result instead of role coverage: ${JSON.stringify(failedOrder)}`
);
const inactiveResonance = evaluateOutcome({
planId: 'resonance',
selectedUnitIds,
formationAssignments,
unitReasons,
performance: {
...performance,
unitStats: performance.unitStats.map((stats) => ({ ...stats, sortieExtendedBondTriggers: 0 }))
},
outcome: 'victory',
objectives: [{ achieved: true }],
activeBondCount: 1
});
assert(
inactiveResonance.kpis.find((entry) => entry.id === 'focus')?.status === 'partial',
`An active bond without an actual pursuit/extension must not count as a resonance hit: ${JSON.stringify(inactiveResonance)}`
);
const unmetSignature = evaluateOutcome({
planId: 'terrain-strategy',
selectedUnitIds,
formationAssignments,
unitReasons: { ...unitReasons, 'fixture-front': '전열을 지킵니다. · 고유 시험 전법' },
performance: {
...performance,
unitStats: performance.unitStats.map((stats) => stats.unitId === 'fixture-front'
? { ...stats, strategyUses: 0, signatureStrategyUses: 0 }
: stats)
},
outcome: 'victory',
objectives: [{ achieved: true }],
activeBondCount: 1
});
const unmetSignatureUnit = unmetSignature.unitContributions.find((entry) => entry.unitId === 'fixture-front');
const supportMetric = unmetSignature.unitContributions
.find((entry) => entry.unitId === 'fixture-support')
?.metrics.find((entry) => entry.label === '지원량');
assert(
unmetSignatureUnit?.status === 'miss' && unmetSignatureUnit.score === 0 &&
unmetSignatureUnit.headline.includes('고유 전법 미사용') && compactContribution(unmetSignatureUnit) === '고유 미사용',
`A source reason that calls for a signature strategy must be evaluated, not only displayed: ${JSON.stringify(unmetSignatureUnit)}`
);
assert(
supportMetric?.unit === '체력' && supportMetric.value === 6,
`Support is accumulated healing volume and must not be labeled as an action count: ${JSON.stringify(supportMetric)}`
);
const unmetGeneralStrategy = evaluateOutcome({
planId: 'terrain-strategy',
selectedUnitIds,
formationAssignments,
unitReasons: { ...unitReasons, 'fixture-front': '지형 적성과 전법 범위를 보강합니다.' },
performance: {
...performance,
unitStats: performance.unitStats.map((stats) => stats.unitId === 'fixture-front'
? { ...stats, strategyUses: 0, signatureStrategyUses: 0 }
: stats)
},
outcome: 'victory',
objectives: [{ achieved: true }],
activeBondCount: 1
});
const unmetGeneralStrategyUnit = unmetGeneralStrategy.unitContributions.find((entry) => entry.unitId === 'fixture-front');
assert(
unmetGeneralStrategyUnit?.status === 'partial' && compactContribution(unmetGeneralStrategyUnit) === '전법 미사용',
`A non-signature strategy reason must expose its exact compact failure label: ${JSON.stringify(unmetGeneralStrategyUnit)}`
);
const unmetPursuit = evaluateOutcome({
planId: 'resonance',
selectedUnitIds,
formationAssignments,
unitReasons: { ...unitReasons, 'fixture-front': '공명 관계와 추격 연결을 맡습니다.' },
performance: {
...performance,
unitStats: performance.unitStats.map((stats) => stats.unitId === 'fixture-front'
? { ...stats, sortieExtendedBondTriggers: 0 }
: stats)
},
outcome: 'victory',
objectives: [{ achieved: true }],
activeBondCount: 1
});
const unmetPursuitUnit = unmetPursuit.unitContributions.find((entry) => entry.unitId === 'fixture-front');
assert(
unmetPursuitUnit?.status === 'partial' && compactContribution(unmetPursuitUnit) === '추격 미발동',
`A resonance reason without a successful pursuit must expose its exact compact failure label: ${JSON.stringify(unmetPursuitUnit)}`
);
const mismatchedExecution = evaluateOutcome({
planId: 'order',
selectedUnitIds,
formationAssignments,
unitReasons,
performance: {
selectedUnitIds: ['fixture-front', 'fixture-support', 'fixture-unexpected'],
formationAssignments: { 'fixture-front': 'front', 'fixture-support': 'support', 'fixture-unexpected': 'flank' },
unitStats: [
performance.unitStats[0],
performance.unitStats[2],
outcomeStats('fixture-unexpected', { actions: 1, damageDealt: 4 })
]
},
outcome: 'victory',
objectives: [{ achieved: true }],
activeBondCount: 1,
sortieOrder: { achieved: true, score: 100 }
});
const missingRecommendedUnit = mismatchedExecution.unitContributions.find((entry) => entry.unitId === 'fixture-flank');
const mismatchDisplayIds = displayUnitIds(
['fixture-front', 'fixture-support', 'fixture-unexpected'],
mismatchedExecution.unitContributions
);
assert(
mismatchedExecution.kpis.find((entry) => entry.id === 'execution')?.status !== 'hit' &&
missingRecommendedUnit?.deployed === false && missingRecommendedUnit.status === 'miss' &&
compactContribution(missingRecommendedUnit) === '미출전' &&
JSON.stringify(mismatchDisplayIds.slice(0, 2)) === JSON.stringify(['fixture-flank', 'fixture-unexpected']),
`Roster mismatches must retain the missing recommendation row and expose a concise non-deployment reason: ${JSON.stringify(mismatchedExecution)}`
);
const missed = evaluateOutcome({
planId: 'terrain-strategy',
selectedUnitIds,
formationAssignments,
unitReasons,
performance: {
selectedUnitIds: [...selectedUnitIds],
formationAssignments: { ...formationAssignments },
unitStats: selectedUnitIds.map((unitId) => outcomeStats(unitId))
},
outcome: 'defeat',
objectives: [{ achieved: false }, { achieved: false }],
activeBondCount: 0
});
assert(missed.status === 'miss', `Expected an inactive defeated recommendation to miss: ${JSON.stringify(missed)}`);
assert(missed.unitContributions.every((entry) => entry.status === 'miss'), 'Inactive officers must expose missed contribution rows.');
}
function outcomeStats(unitId, overrides = {}) {
return {
unitId,
hpBefore: 40,
maxHpBefore: 40,
damageDealt: 0,
damageTaken: 0,
defeats: 0,
actions: 0,
support: 0,
strategyUses: 0,
signatureStrategyUses: 0,
sortieBonusDamage: 0,
sortiePreventedDamage: 0,
sortieBonusHealing: 0,
sortieExtendedBondTriggers: 0,
intentDefeats: 0,
intentLineBreaks: 0,
intentGuardSuccesses: 0,
...overrides
};
}
function validateBlockedPlans(plans, expectedReasonFragment, label) {
assert(
JSON.stringify(plans.map((plan) => plan.id)) === JSON.stringify(['order', 'resonance', 'terrain-strategy']),