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 { 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) .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); verifyRecommendationImprovement(buildSortieRecommendationImprovement); 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 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 = { '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); } }