Files
heros_web/scripts/verify-sortie-recommendations.mjs

718 lines
30 KiB
JavaScript

import { createServer } from 'vite';
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
try {
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { terrainRules, unitClasses } = await server.ssrLoadModule('/src/game/data/battleRules.ts');
const {
buildSortieRecommendationPlans,
sortieRecommendationPlanIds
} = 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)
.filter((usable) => usable.signatureOwnerId)
.map((usable) => [usable.signatureOwnerId, usable.name])
);
assert(
JSON.stringify(sortieRecommendationPlanIds) === JSON.stringify(['order', 'resonance', 'terrain-strategy']),
`Expected the three recommendation profiles to retain their stable order: ${JSON.stringify(sortieRecommendationPlanIds)}`
);
const scenarioEntries = Object.entries(battleScenarios);
const formationRoles = new Set(sortieFormationRoles);
const differentiatedProfileBattleIds = new Set();
const differentiatedOrderBattleIds = new Set();
let evaluatedPlanCount = 0;
scenarioEntries.forEach(([battleId, scenario], battleIndex) => {
assert(scenario.sortie, `${battleId}: expected sortie data before recommendation verification`);
const profileSignaturesByOrder = new Map();
const orderPlanSignatures = new Set();
sortieOrderIds.forEach((orderId) => {
const context = buildScenarioContext(scenario, orderId, terrainRules, unitClasses);
const frozenInput = JSON.stringify(context);
const plans = buildSortieRecommendationPlans(context);
const repeated = buildSortieRecommendationPlans(context);
assert(
JSON.stringify(plans) === JSON.stringify(repeated),
`${battleId}/${orderId}: repeated recommendation calculation must be deterministic`
);
assert(
JSON.stringify(context) === frozenInput,
`${battleId}/${orderId}: recommendation calculation must not mutate its context`
);
assert(
JSON.stringify(plans.map((plan) => plan.id)) === JSON.stringify(sortieRecommendationPlanIds),
`${battleId}/${orderId}: expected one plan for every stable recommendation id: ${JSON.stringify(plans)}`
);
plans.forEach((plan) => {
validateApplicablePlan({
plan,
battleId,
orderId,
context,
formationRoles,
getUnitStrategyCoverage,
signatureStrategyByOwner,
expectedCount: scenario.sortie.sortieLimit
});
evaluatedPlanCount += 1;
});
profileSignaturesByOrder.set(orderId, new Set(plans.map(planProjectionSignature)));
orderPlanSignatures.add(planProjectionSignature(plans.find((plan) => plan.id === 'order')));
});
if ([...profileSignaturesByOrder.values()].some((signatures) => signatures.size > 1)) {
differentiatedProfileBattleIds.add(battleId);
}
if (orderPlanSignatures.size > 1) {
differentiatedOrderBattleIds.add(battleId);
}
const alliedCount = scenario.units.filter((unit) => unit.faction === 'ally').length;
if (alliedCount <= scenario.sortie.sortieLimit) {
assert(
battleIndex < 15,
`${battleId}: a no-choice roster unexpectedly appeared after the established first 15 battles`
);
}
});
assert(
differentiatedProfileBattleIds.size > 0,
'Expected at least one campaign battle to produce materially different profile projections.'
);
assert(
differentiatedOrderBattleIds.size > 0,
'Expected elite, mobile, and siege orders to change at least one order-profile projection.'
);
const fixtureScenario = battleScenarios['sixtieth-battle-wudu-yinping'];
assert(fixtureScenario?.sortie, 'Expected the Wudu/Yinping battle to remain available as the broad-roster fixture.');
const fixtureContext = buildScenarioContext(fixtureScenario, 'mobile', terrainRules, unitClasses);
verifyRosterIndexDeterminism(fixtureContext, buildSortieRecommendationPlans);
verifyStrongBondPairSeed(fixtureContext, buildSortieRecommendationPlans);
verifyUnavailableOptionalCandidates(fixtureContext, buildSortieRecommendationPlans, formationRoles);
verifyUnavailableRequiredCandidate(fixtureContext, buildSortieRecommendationPlans);
verifyRequiredExcludedConflict(fixtureContext, buildSortieRecommendationPlans);
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; ` +
`${differentiatedProfileBattleIds.size} battles vary by profile and ${differentiatedOrderBattleIds.size} vary by selected order, with synthetic availability, required, exclusion, duplicate, and fallback cases.`
);
} finally {
await server.close();
}
function buildScenarioContext(scenario, orderId, terrainRules, unitClasses) {
const sortie = scenario.sortie;
const excludedUnitIds = sortie.excludedUnits ?? [];
const excluded = new Set(excludedUnitIds);
const recommendationById = new Map(sortie.recommendedUnits.map((entry) => [entry.unitId, entry]));
const candidates = scenario.units
.filter((unit) => unit.faction === 'ally')
.map((unit, rosterIndex) => ({
unit,
available: unit.hp > 0 && !excluded.has(unit.id),
terrainScore: terrainScoreForUnit(unit, scenario, terrainRules, unitClasses),
preferredRole: recommendationById.get(unit.id)?.role,
rosterIndex
}));
return {
battleId: scenario.id,
maxUnits: sortie.sortieLimit,
orderId,
candidates,
requiredUnitIds: sortie.requiredUnits,
excludedUnitIds,
recommendedUnits: sortie.recommendedUnits,
recommendedClasses: sortie.recommendedClasses,
bonds: scenario.bonds
};
}
function terrainScoreForUnit(unit, scenario, terrainRules, unitClasses) {
const counts = new Map();
scenario.map.terrain.flat().forEach((terrain) => {
if (terrainRules[terrain]?.passable !== false) {
counts.set(terrain, (counts.get(terrain) ?? 0) + 1);
}
});
const total = [...counts.values()].reduce((sum, count) => sum + count, 0);
const ratings = unitClasses[unit.classKey]?.terrainRatings ?? {};
return total > 0
? Math.round([...counts.entries()].reduce((sum, [terrain, count]) => sum + (ratings[terrain] ?? 0) * count, 0) / total)
: 100;
}
function validateApplicablePlan({
plan,
battleId,
orderId,
context,
formationRoles,
getUnitStrategyCoverage,
signatureStrategyByOwner,
expectedCount
}) {
const candidateById = new Map(
context.candidates
.filter((candidate) => candidate.available !== false && candidate.unit.faction === 'ally' && candidate.unit.hp > 0)
.filter((candidate) => !(context.excludedUnitIds ?? []).includes(candidate.unit.id))
.map((candidate) => [candidate.unit.id, candidate])
);
const selected = new Set(plan.selectedUnitIds);
const assignmentIds = Object.keys(plan.formationAssignments).sort();
const selectedSorted = [...selected].sort();
assert(plan.applicable === true, `${battleId}/${orderId}/${plan.id}: expected an applicable plan: ${JSON.stringify(plan)}`);
assert(!plan.blockedReason, `${battleId}/${orderId}/${plan.id}: applicable plan must not have a blocked reason`);
assert(
plan.selectedUnitIds.length === expectedCount && selected.size === expectedCount,
`${battleId}/${orderId}/${plan.id}: expected ${expectedCount} unique selected units: ${JSON.stringify(plan.selectedUnitIds)}`
);
assert(
plan.selectedUnitIds.every((unitId) => candidateById.has(unitId)),
`${battleId}/${orderId}/${plan.id}: selected an unavailable, excluded, injured, non-allied, or unknown unit: ${JSON.stringify(plan.selectedUnitIds)}`
);
assert(
(context.requiredUnitIds ?? []).every((unitId) => selected.has(unitId)),
`${battleId}/${orderId}/${plan.id}: omitted a required unit: ${JSON.stringify({ required: context.requiredUnitIds, selected: plan.selectedUnitIds })}`
);
assert(
JSON.stringify(assignmentIds) === JSON.stringify(selectedSorted),
`${battleId}/${orderId}/${plan.id}: formation assignments must match selected ids exactly: ${JSON.stringify(plan.formationAssignments)}`
);
assert(
Object.values(plan.formationAssignments).every((role) => formationRoles.has(role)),
`${battleId}/${orderId}/${plan.id}: formation contains an invalid role: ${JSON.stringify(plan.formationAssignments)}`
);
assert(
Number.isInteger(plan.score) && plan.score >= 0 && plan.score <= 100,
`${battleId}/${orderId}/${plan.id}: score must be an integer from 0 to 100: ${plan.score}`
);
assertNonEmptyText(plan.label, `${battleId}/${orderId}/${plan.id}: label`);
assertNonEmptyText(plan.title, `${battleId}/${orderId}/${plan.id}: title`);
assertNonEmptyText(plan.summary, `${battleId}/${orderId}/${plan.id}: summary`);
assertNonEmptyText(plan.tradeoff, `${battleId}/${orderId}/${plan.id}: tradeoff`);
assert(
Array.isArray(plan.highlights) && plan.highlights.length >= 2 && plan.highlights.every(isNonEmptyText),
`${battleId}/${orderId}/${plan.id}: expected at least two non-empty highlights: ${JSON.stringify(plan.highlights)}`
);
assert(
JSON.stringify(Object.keys(plan.unitReasons).sort()) === JSON.stringify(selectedSorted) &&
Object.values(plan.unitReasons).every(isNonEmptyText),
`${battleId}/${orderId}/${plan.id}: expected one reason for every selected unit: ${JSON.stringify(plan.unitReasons)}`
);
if (signatureStrategyByOwner) {
plan.selectedUnitIds.forEach((unitId) => {
const signatureName = signatureStrategyByOwner.get(unitId);
if (signatureName) {
assert(
plan.unitReasons[unitId].includes(`고유 ${signatureName}`),
`${battleId}/${orderId}/${plan.id}: signature strategy must appear in ${unitId}'s recommendation reason: ${plan.unitReasons[unitId]}`
);
}
});
}
if (getUnitStrategyCoverage) {
const expectedStrategyCoverage = getUnitStrategyCoverage(plan.selectedUnitIds);
assert(
plan.metrics.strategyCoverageCount === expectedStrategyCoverage.count &&
plan.metrics.strategyCoverageTotal === expectedStrategyCoverage.total &&
JSON.stringify(plan.metrics.coveredStrategyLabels) === JSON.stringify(expectedStrategyCoverage.covered.map((definition) => definition.label)) &&
JSON.stringify(plan.metrics.missingStrategyLabels) === JSON.stringify(expectedStrategyCoverage.missing.map((definition) => definition.label)),
`${battleId}/${orderId}/${plan.id}: recommendation metrics must match tag-based strategy coverage: ${JSON.stringify({
actual: plan.metrics,
expected: expectedStrategyCoverage
})}`
);
}
validateMetrics(plan.metrics, orderId, `${battleId}/${orderId}/${plan.id}`);
}
function validateMetrics(metrics, orderId, context) {
assert(metrics.orderId === orderId, `${context}: metrics must retain selected order ${orderId}: ${JSON.stringify(metrics)}`);
assert(inRange(metrics.orderFit, 0, 100), `${context}: orderFit must be within 0..100: ${metrics.orderFit}`);
assert(Number.isInteger(metrics.activeBondCount) && metrics.activeBondCount >= 0, `${context}: invalid active bond count`);
assert(Number.isInteger(metrics.pursuitPairCount) && metrics.pursuitPairCount >= 0, `${context}: invalid pursuit pair count`);
assert(Number.isFinite(metrics.terrainScore) && metrics.terrainScore > 0, `${context}: invalid terrain score ${metrics.terrainScore}`);
assertNonEmptyText(metrics.terrainGrade, `${context}: terrain grade`);
assert(
Number.isInteger(metrics.strategyCoverageCount) &&
Number.isInteger(metrics.strategyCoverageTotal) &&
metrics.strategyCoverageCount >= 0 &&
metrics.strategyCoverageCount <= metrics.strategyCoverageTotal,
`${context}: invalid strategy coverage: ${JSON.stringify(metrics)}`
);
assert(
metrics.coveredStrategyLabels.length === metrics.strategyCoverageCount &&
metrics.coveredStrategyLabels.length + metrics.missingStrategyLabels.length === metrics.strategyCoverageTotal,
`${context}: strategy labels must match coverage counts: ${JSON.stringify(metrics)}`
);
assert(Number.isInteger(metrics.roleCoverage) && inRange(metrics.roleCoverage, 0, 3), `${context}: invalid role coverage`);
assert(inRange(metrics.recommendedClassCoverage, 0, 100), `${context}: invalid recommended-class coverage`);
}
function verifyUnavailableOptionalCandidates(base, buildPlans, formationRoles) {
const required = new Set(base.requiredUnitIds);
const optionalIds = base.candidates.filter((candidate) => !required.has(candidate.unit.id)).slice(0, 2).map((candidate) => candidate.unit.id);
const [injuredId, excludedId] = optionalIds;
assert(injuredId && excludedId, 'Expected two optional units for the availability fixture.');
const context = {
...base,
candidates: base.candidates.map((candidate) => candidate.unit.id === injuredId
? { ...candidate, unit: { ...candidate.unit, hp: 0 }, available: false, blockedReason: '부상' }
: candidate),
excludedUnitIds: [...(base.excludedUnitIds ?? []), excludedId]
};
buildPlans(context).forEach((plan) => {
validateApplicablePlan({
plan,
battleId: 'fixture-optional-unavailable',
orderId: base.orderId,
context,
formationRoles,
expectedCount: base.maxUnits
});
assert(
!plan.selectedUnitIds.includes(injuredId) && !plan.selectedUnitIds.includes(excludedId),
`Optional injured/excluded units must be omitted: ${JSON.stringify(plan.selectedUnitIds)}`
);
});
}
function verifyRosterIndexDeterminism(base, buildPlans) {
const forward = buildPlans(base);
const reversedCandidates = {
...base,
candidates: [...base.candidates].reverse()
};
const reversed = buildPlans(reversedCandidates);
assert(
JSON.stringify(reversed) === JSON.stringify(forward),
`Explicit rosterIndex values must make recommendations independent of candidate array order: ${JSON.stringify({
forward: forward.map(planProjectionSignature),
reversed: reversed.map(planProjectionSignature)
})}`
);
}
function verifyStrongBondPairSeed(base, buildPlans) {
const source = base.candidates[0];
const candidate = (unitId, rosterIndex, terrainScore) => ({
...source,
unit: {
...source.unit,
id: unitId,
name: unitId,
hp: source.unit.maxHp
},
terrainScore,
rosterIndex
});
const bondUnitIds = ['synthetic-bond-a', 'synthetic-bond-b'];
const terrainUnitIds = ['synthetic-terrain-c', 'synthetic-terrain-d'];
const context = {
battleId: 'fixture-strong-bond-pair-seed',
maxUnits: 2,
orderId: 'elite',
candidates: [
candidate(bondUnitIds[0], 0, 100),
candidate(bondUnitIds[1], 1, 100),
candidate(terrainUnitIds[0], 2, 106),
candidate(terrainUnitIds[1], 3, 106)
],
requiredUnitIds: [],
excludedUnitIds: [],
recommendedUnits: [],
recommendedClasses: [],
bonds: [
{
id: 'synthetic-level-100-bond',
unitIds: bondUnitIds,
title: '강한 합성 공명',
level: 100,
exp: 0
}
]
};
const plans = buildPlans(context);
const resonance = plans.find((plan) => plan.id === 'resonance');
const terrain = plans.find((plan) => plan.id === 'terrain-strategy');
assert(
resonance?.applicable === true &&
resonance.metrics.activeBondCount === 1 &&
bondUnitIds.every((unitId) => resonance.selectedUnitIds.includes(unitId)),
`The resonance profile must seed both ends of a strong bond instead of getting trapped by one-member greedy additions: ${JSON.stringify(resonance)}`
);
assert(
terrain?.applicable === true && terrainUnitIds.every((unitId) => terrain.selectedUnitIds.includes(unitId)),
`The control profile must still prefer the strictly stronger unbonded terrain pair: ${JSON.stringify(terrain)}`
);
}
function verifyUnavailableRequiredCandidate(base, buildPlans) {
const requiredId = base.requiredUnitIds[0];
const context = {
...base,
candidates: base.candidates.map((candidate) => candidate.unit.id === requiredId
? { ...candidate, unit: { ...candidate.unit, hp: 0 }, available: false, blockedReason: '부상' }
: candidate)
};
validateBlockedPlans(buildPlans(context), requiredId, 'injured required unit');
}
function verifyRequiredExcludedConflict(base, buildPlans) {
const requiredId = base.requiredUnitIds[0];
const context = { ...base, excludedUnitIds: [...(base.excludedUnitIds ?? []), requiredId] };
validateBlockedPlans(buildPlans(context), requiredId, 'required/excluded conflict');
}
function verifyRequiredOverflow(base, buildPlans) {
const requiredUnitIds = base.candidates.slice(0, 3).map((candidate) => candidate.unit.id);
const context = { ...base, maxUnits: 2, requiredUnitIds };
validateBlockedPlans(buildPlans(context), '출전 한도 2명', 'required overflow');
}
function verifyDuplicateAndNonAllyCandidates(base, buildPlans, formationRoles) {
const duplicate = base.candidates[1];
const enemySource = base.candidates[2];
const enemyId = 'synthetic-enemy-candidate';
const context = {
...base,
candidates: [
duplicate,
...base.candidates,
{ ...enemySource, unit: { ...enemySource.unit, id: enemyId, faction: 'enemy' }, rosterIndex: 999 }
]
};
buildPlans(context).forEach((plan) => {
validateApplicablePlan({
plan,
battleId: 'fixture-duplicate-non-ally',
orderId: base.orderId,
context,
formationRoles,
expectedCount: base.maxUnits
});
assert(!plan.selectedUnitIds.includes(enemyId), `Non-allied candidate must be omitted: ${JSON.stringify(plan.selectedUnitIds)}`);
});
}
function verifyMissingOrderFallback(base, buildPlans, formationRoles) {
const context = { ...base };
delete context.orderId;
const plans = buildPlans(context);
plans.forEach((plan) => {
validateApplicablePlan({
plan,
battleId: 'fixture-order-fallback',
orderId: undefined,
context,
formationRoles,
expectedCount: base.maxUnits
});
});
const orderPlan = plans.find((plan) => plan.id === 'order');
assert(
orderPlan.tradeoff.includes('군령 미선택') && orderPlan.highlights.some((line) => line.includes('미선택')),
`Order fallback must explain its balanced calculation: ${JSON.stringify(orderPlan)}`
);
}
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']),
`${label}: blocked result must retain all three stable cards: ${JSON.stringify(plans)}`
);
plans.forEach((plan) => {
assert(
plan.applicable === false &&
plan.selectedUnitIds.length === 0 &&
Object.keys(plan.formationAssignments).length === 0 &&
plan.score === 0 &&
plan.blockedReason?.includes(expectedReasonFragment) &&
plan.summary === plan.blockedReason,
`${label}/${plan.id}: expected a safe blocked plan: ${JSON.stringify(plan)}`
);
});
}
function planProjectionSignature(plan) {
return JSON.stringify({ selectedUnitIds: plan.selectedUnitIds, formationAssignments: plan.formationAssignments });
}
function inRange(value, minimum, maximum) {
return Number.isFinite(value) && value >= minimum && value <= maximum;
}
function isNonEmptyText(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function assertNonEmptyText(value, context) {
assert(isNonEmptyText(value), `${context} must be a non-empty string: ${JSON.stringify(value)}`);
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}