feat: connect sortie recommendations to battle results
This commit is contained in:
@@ -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']),
|
||||
|
||||
Reference in New Issue
Block a user