import { getUnitStrategyCoverage, getUnitStrategyNames } from './battleUsables'; import type { UnitClassKey } from './battleRules'; import type { UnitData } from './scenario'; import type { SortieFormationAssignments, SortieFormationRole } from './sortieDeployment'; import { coreSortieResonanceMinLevel, coreSortieSynergyRoles, evaluateSortiePursuitPairs, evaluateSortieSynergy, type SortieSynergyBond } from './sortieSynergy'; import type { CampaignSortieOrderId } from './sortieOrders'; export const sortieRecommendationPlanIds = ['order', 'resonance', 'terrain-strategy'] as const; export type SortieRecommendationPlanId = (typeof sortieRecommendationPlanIds)[number]; export type SortieRecommendationCandidate = { unit: UnitData; available?: boolean; blockedReason?: string; terrainScore?: number; preferredRole?: SortieFormationRole; rosterIndex?: number; }; export type SortieRecommendationEntry = { unitId: string; reason?: string; role?: SortieFormationRole; }; export type SortieRecommendationClassEntry = { label: string; classKeys: readonly UnitClassKey[]; reason?: string; }; export type SortieRecommendationContext = { battleId?: string; maxUnits: number; orderId?: CampaignSortieOrderId; candidates: readonly SortieRecommendationCandidate[]; requiredUnitIds?: readonly string[]; excludedUnitIds?: readonly string[]; recommendedUnits?: readonly SortieRecommendationEntry[]; recommendedClasses?: readonly SortieRecommendationClassEntry[]; bonds?: readonly SortieSynergyBond[]; coreBondId?: string; }; export type SortieRecommendationMetrics = { orderId?: CampaignSortieOrderId; orderFit: number; activeBondCount: number; pursuitPairCount: number; strongestBondLabel?: string; terrainScore: number; terrainGrade: string; strategyCoverageCount: number; strategyCoverageTotal: number; coveredStrategyLabels: string[]; missingStrategyLabels: string[]; roleCoverage: number; recommendedClassCoverage: number; }; export type SortieRecommendationPlan = { id: SortieRecommendationPlanId; label: string; title: string; applicable: boolean; blockedReason?: string; selectedUnitIds: string[]; formationAssignments: SortieFormationAssignments; score: number; summary: string; highlights: string[]; tradeoff: string; metrics: SortieRecommendationMetrics; unitReasons: Record; }; type NormalizedCandidate = SortieRecommendationCandidate & { rosterIndex: number; terrainScore: number; preferredRole: SortieFormationRole; }; type PartyEvaluation = { score: number; metrics: SortieRecommendationMetrics; formationAssignments: SortieFormationAssignments; }; const planMetadata: Record = { order: { label: '군령 달성형', title: '군령 조건부터 맞추는 안정안' }, resonance: { label: '공명·추격형', title: '인연과 연계 공격을 잇는 공격안' }, 'terrain-strategy': { label: '지형·전법형', title: '전장 적성과 전법 폭을 넓히는 대응안' } }; const orderLabels: Record = { elite: '정예', mobile: '기동', siege: '공성' }; export function buildSortieRecommendationPlans(context: SortieRecommendationContext): SortieRecommendationPlan[] { const normalized = normalizeContext(context); const requiredIds = unique(context.requiredUnitIds ?? []); const requiredMissing = requiredIds.filter((unitId) => !normalized.byId.has(unitId)); const blockedReason = requiredMissing.length > 0 ? `필수 무장 ${requiredMissing.join(', ')}이(가) 출전할 수 없습니다.` : requiredIds.length > normalized.maxUnits ? `필수 무장 ${requiredIds.length}명이 출전 한도 ${normalized.maxUnits}명을 넘습니다.` : normalized.maxUnits <= 0 ? '출전 가능한 무장이 없습니다.' : undefined; if (blockedReason) { return sortieRecommendationPlanIds.map((planId) => blockedPlan(planId, context.orderId, blockedReason)); } return sortieRecommendationPlanIds.map((planId) => { const selectedUnitIds = selectParty(planId, normalized.candidates, requiredIds, normalized.maxUnits, context); const evaluation = evaluateParty(planId, selectedUnitIds, normalized.candidates, context); return createPlan(planId, selectedUnitIds, evaluation, normalized.byId, context); }); } function normalizeContext(context: SortieRecommendationContext) { const excludedIds = new Set(context.excludedUnitIds ?? []); const seen = new Set(); const candidates = context.candidates .map((candidate, index) => ({ ...candidate, rosterIndex: finiteInteger(candidate.rosterIndex, index), terrainScore: finiteNumber(candidate.terrainScore, 100), preferredRole: candidate.preferredRole ?? defaultFormationRole(candidate.unit.classKey) })) .filter((candidate) => { const allowed = candidate.available !== false && candidate.unit.faction === 'ally' && candidate.unit.hp > 0 && !excludedIds.has(candidate.unit.id) && !seen.has(candidate.unit.id); if (allowed) { seen.add(candidate.unit.id); } return allowed; }) .sort(compareCandidates); const byId = new Map(candidates.map((candidate) => [candidate.unit.id, candidate])); return { candidates, byId, maxUnits: Math.min(candidates.length, Math.max(0, finiteInteger(context.maxUnits))) }; } function selectParty( planId: SortieRecommendationPlanId, candidates: readonly NormalizedCandidate[], requiredIds: readonly string[], maxUnits: number, context: SortieRecommendationContext ) { let selectedIds = [...requiredIds]; const selected = new Set(selectedIds); if (planId === 'resonance') { const candidateIds = new Set(candidates.map((candidate) => candidate.unit.id)); const seedBond = [...(context.bonds ?? [])] .filter((bond) => bond.unitIds[0] !== bond.unitIds[1] && bond.unitIds.every((unitId) => candidateIds.has(unitId))) .map((bond) => ({ bond, missingUnitIds: bond.unitIds.filter((unitId) => !selected.has(unitId)) })) .filter(({ missingUnitIds }) => missingUnitIds.length > 0 && missingUnitIds.length <= maxUnits - selectedIds.length) .sort((left, right) => Number(right.bond.id === context.coreBondId && right.bond.level >= coreSortieResonanceMinLevel) - Number(left.bond.id === context.coreBondId && left.bond.level >= coreSortieResonanceMinLevel) || right.bond.level - left.bond.level || left.bond.id.localeCompare(right.bond.id) )[0]; seedBond?.missingUnitIds.forEach((unitId) => { selectedIds.push(unitId); selected.add(unitId); }); } while (selectedIds.length < maxUnits) { const next = candidates .filter((candidate) => !selected.has(candidate.unit.id)) .map((candidate) => { const projectedIds = [...selectedIds, candidate.unit.id]; const evaluation = evaluateParty(planId, projectedIds, candidates, context); return { candidate, score: evaluation.score, individualFit: candidateFit(planId, candidate, context), recommendedRank: recommendationRank(candidate.unit.id, context) }; }) .sort((left, right) => right.score - left.score || right.individualFit - left.individualFit || left.recommendedRank - right.recommendedRank || compareCandidates(left.candidate, right.candidate) )[0]; if (!next) { break; } selectedIds.push(next.candidate.unit.id); selected.add(next.candidate.unit.id); } const requiredSet = new Set(requiredIds); for (let pass = 0; pass < 2; pass += 1) { const current = evaluateParty(planId, selectedIds, candidates, context); const swaps = selectedIds .filter((unitId) => !requiredSet.has(unitId)) .flatMap((outgoingId) => candidates .filter((candidate) => !selected.has(candidate.unit.id)) .map((incoming) => { const projectedIds = selectedIds.map((unitId) => unitId === outgoingId ? incoming.unit.id : unitId); const evaluation = evaluateParty(planId, projectedIds, candidates, context); return { outgoingId, incoming, projectedIds, evaluation }; })) .filter((swap) => swap.evaluation.score > current.score) .sort((left, right) => right.evaluation.score - left.evaluation.score || candidateFit(planId, right.incoming, context) - candidateFit(planId, left.incoming, context) || compareCandidates(left.incoming, right.incoming) || left.outgoingId.localeCompare(right.outgoingId) ); const best = swaps[0]; if (!best) { break; } selected.delete(best.outgoingId); selected.add(best.incoming.unit.id); selectedIds = best.projectedIds; } const candidateById = new Map(candidates.map((candidate) => [candidate.unit.id, candidate])); const selectedOptional = selectedIds .filter((unitId) => !requiredSet.has(unitId)) .sort((leftId, rightId) => { const left = candidateById.get(leftId); const right = candidateById.get(rightId); if (!left || !right) { return leftId.localeCompare(rightId); } return candidateFit(planId, right, context) - candidateFit(planId, left, context) || compareCandidates(left, right); }); return [...requiredIds, ...selectedOptional].slice(0, maxUnits); } function evaluateParty( planId: SortieRecommendationPlanId, selectedUnitIds: readonly string[], candidates: readonly NormalizedCandidate[], context: SortieRecommendationContext ): PartyEvaluation { const selected = new Set(selectedUnitIds); const selectedCandidates = candidates.filter((candidate) => selected.has(candidate.unit.id)); const formationAssignments = assignFormationRoles(planId, selectedCandidates, context); const members = candidates.map((candidate) => ({ id: candidate.unit.id, name: candidate.unit.name, role: formationAssignments[candidate.unit.id] ?? candidate.preferredRole, terrainScore: candidate.terrainScore })); const synergy = evaluateSortieSynergy({ members, bonds: context.bonds ?? [], selectedUnitIds, battleId: context.battleId }); const pursuit = evaluateSortiePursuitPairs({ members, bonds: context.bonds ?? [], selectedUnitIds, selectableUnitIds: [], battleId: context.battleId, coreBondId: context.coreBondId }); const strategy = getUnitStrategyCoverage(selectedUnitIds); const roleCoverage = synergy.coveredRoleCount; const recommendedClassCoverage = classCoverage(selectedCandidates, context.recommendedClasses ?? []); const recommendedCoverage = unitRecommendationCoverage(selectedUnitIds, candidates, context.recommendedUnits ?? []); const terrainFit = clamp(((synergy.terrainScore - 80) / 35) * 100); const survival = average(selectedCandidates.map((candidate) => clamp((candidate.unit.hp / Math.max(1, candidate.unit.maxHp)) * 70 + candidate.unit.stats.leadership * 0.3) )); const power = average(selectedCandidates.map((candidate) => clamp( candidate.unit.stats.might * 0.4 + candidate.unit.stats.leadership * 0.25 + candidate.unit.attack * 0.55 + (candidate.unit.hp / Math.max(1, candidate.unit.maxHp)) * 15 ))); const mobility = average(selectedCandidates.map((candidate) => clamp( candidate.unit.stats.agility * 0.48 + candidate.unit.stats.might * 0.17 + clamp((candidate.unit.move / 8) * 100) * 0.35 ))); const flankUnits = selectedCandidates.filter((candidate) => formationAssignments[candidate.unit.id] === 'flank'); const flankPower = flankUnits.length > 0 ? average(flankUnits.map((candidate) => clamp(candidate.unit.stats.agility * 0.5 + candidate.unit.stats.might * 0.25 + candidate.unit.attack * 0.6))) : 0; const siegeUnits = selectedCandidates.filter((candidate) => { const role = formationAssignments[candidate.unit.id]; return role === 'front' || role === 'support'; }); const siegePower = siegeUnits.length > 0 ? average(siegeUnits.map((candidate) => { const role = formationAssignments[candidate.unit.id]; return role === 'front' ? clamp(candidate.unit.stats.might * 0.3 + candidate.unit.stats.leadership * 0.35 + candidate.unit.attack * 0.55) : clamp(candidate.unit.stats.intelligence * 0.55 + candidate.unit.stats.leadership * 0.2 + getUnitStrategyNames([candidate.unit.id]).length * 12); })) : 0; const synergyFit = clamp( synergy.activeBondCount * 18 + (synergy.strongestBond?.level ?? 0) * 0.45 + synergy.activeBonds.reduce((total, bond) => total + bond.damageBonus, 0) * 1.5 ); const pursuitFit = clamp( pursuit.activePairs.length * 28 + pursuit.activePairs.reduce((total, pair) => total + pair.chainRate, 0) * 1.8 ); const selectedCore = pursuit.activePairs.some((pair) => pair.core); const roleFit = (roleCoverage / coreSortieSynergyRoles.length) * 100; const strategyFit = strategy.total > 0 ? (strategy.count / strategy.total) * 100 : 100; const classFit = (context.recommendedClasses?.length ?? 0) > 0 ? recommendedClassCoverage : 100; const unitFit = (context.recommendedUnits?.length ?? 0) > 0 ? recommendedCoverage : 100; const orderFit = orderSupportScore(context.orderId, { survival, power, mobility, flankPower, siegePower, terrainFit, strategyFit, roleFit, classFit, unitFit, synergyFit }); const rawScore = planId === 'order' ? orderFit : planId === 'resonance' ? context.coreBondId ? synergyFit * 0.4 + pursuitFit * 0.2 + (selectedCore ? 100 : 0) * 0.15 + roleFit * 0.1 + terrainFit * 0.1 + strategyFit * 0.05 : synergyFit * 0.5 + pursuitFit * 0.25 + roleFit * 0.1 + terrainFit * 0.1 + strategyFit * 0.05 : terrainFit * 0.45 + strategyFit * 0.3 + classFit * 0.1 + roleFit * 0.1 + unitFit * 0.05; return { score: round(rawScore), formationAssignments, metrics: { orderId: context.orderId, orderFit: round(orderFit), activeBondCount: synergy.activeBondCount, pursuitPairCount: pursuit.activePairs.length, strongestBondLabel: synergy.strongestBond ? `${synergy.strongestBond.title} Lv${synergy.strongestBond.level}` : undefined, terrainScore: synergy.terrainScore, terrainGrade: synergy.terrainGrade, strategyCoverageCount: strategy.count, strategyCoverageTotal: strategy.total, coveredStrategyLabels: strategy.covered.map((definition) => definition.label), missingStrategyLabels: strategy.missing.map((definition) => definition.label), roleCoverage, recommendedClassCoverage: round(recommendedClassCoverage) } }; } function assignFormationRoles( planId: SortieRecommendationPlanId, candidates: readonly NormalizedCandidate[], context: SortieRecommendationContext ) { const recommendationById = new Map((context.recommendedUnits ?? []).map((entry) => [entry.unitId, entry])); const assignments = candidates.reduce((result, candidate) => { const recommendedRole = recommendationById.get(candidate.unit.id)?.role; result[candidate.unit.id] = recommendedRole ?? profilePreferredRole(planId, candidate, context.orderId); return result; }, {}); if (candidates.length < coreSortieSynergyRoles.length) { return assignments; } for (const missingRole of coreSortieSynergyRoles) { const counts = roleCounts(candidates, assignments); if (counts[missingRole] > 0) { continue; } const replacement = candidates .filter((candidate) => { const currentRole = assignments[candidate.unit.id] ?? 'reserve'; return currentRole === 'reserve' || counts[currentRole] > 1; }) .sort((left, right) => roleAffinity(right, missingRole) - roleAffinity(left, missingRole) || compareCandidates(left, right) )[0]; if (replacement) { assignments[replacement.unit.id] = missingRole; } } return assignments; } function profilePreferredRole( planId: SortieRecommendationPlanId, candidate: NormalizedCandidate, orderId?: CampaignSortieOrderId ): SortieFormationRole { if (planId === 'order' && orderId === 'mobile' && (candidate.unit.classKey === 'cavalry' || candidate.unit.stats.agility >= 80)) { return 'flank'; } if (planId === 'order' && orderId === 'siege') { if (['strategist', 'archer', 'quartermaster'].includes(candidate.unit.classKey)) { return 'support'; } if (candidate.unit.stats.leadership >= candidate.unit.stats.agility) { return 'front'; } } if (planId === 'resonance' && getUnitStrategyNames([candidate.unit.id]).length > 1) { return 'support'; } return candidate.preferredRole; } function createPlan( planId: SortieRecommendationPlanId, selectedUnitIds: string[], evaluation: PartyEvaluation, candidateById: ReadonlyMap, context: SortieRecommendationContext ): SortieRecommendationPlan { const metadata = planMetadata[planId]; const metrics = evaluation.metrics; const orderLabel = context.orderId ? orderLabels[context.orderId] : '미선택'; const highlights = planId === 'order' ? [ `${orderLabel} 군령 지원도 ${metrics.orderFit} · 역할 ${metrics.roleCoverage}/3`, `지형 ${metrics.terrainGrade} ${metrics.terrainScore} · 전법 ${metrics.strategyCoverageCount}/${metrics.strategyCoverageTotal}` ] : planId === 'resonance' ? [ `활성 공명 ${metrics.activeBondCount}개 · 추격 ${metrics.pursuitPairCount}조`, metrics.strongestBondLabel ? `핵심 연결 ${metrics.strongestBondLabel}` : '활성 공명보다 역할 균형을 우선 보완' ] : [ `지형 ${metrics.terrainGrade} ${metrics.terrainScore} · 역할 ${metrics.roleCoverage}/3`, `전법 ${metrics.strategyCoverageCount}/${metrics.strategyCoverageTotal} · ${metrics.coveredStrategyLabels.join('·') || '전법 없음'}` ]; const tradeoff = planId === 'order' ? context.orderId ? '공명 수보다 선택한 군령의 달성 조건을 먼저 맞춥니다.' : '군령 미선택 상태라 생존·역할 균형을 기준으로 계산했습니다.' : planId === 'resonance' ? '지형 최고점보다 강한 인연과 추격 연결을 우선합니다.' : metrics.missingStrategyLabels.length > 0 ? `전법 ${metrics.missingStrategyLabels.join('·')} 공백은 남지만 지형 대응을 우선합니다.` : '개별 화력보다 지형 적성과 전법 범위를 우선합니다.'; const recommendedById = new Map((context.recommendedUnits ?? []).map((entry) => [entry.unitId, entry])); const required = new Set(context.requiredUnitIds ?? []); const unitReasons = Object.fromEntries(selectedUnitIds.map((unitId) => { const candidate = candidateById.get(unitId); const authoredReason = recommendedById.get(unitId)?.reason; const reason = required.has(unitId) ? '이번 전투의 필수 출전 무장입니다.' : authoredReason ? authoredReason : planId === 'order' ? `${orderLabel} 군령의 ${formationRoleLabel(evaluation.formationAssignments[unitId])} 기여를 기대합니다.` : planId === 'resonance' ? '공명 관계와 추격 연결 가능성을 높입니다.' : `지형 적성 ${candidate?.terrainScore ?? 100}과 전법 범위를 보강합니다.`; return [unitId, reason]; })); return { id: planId, label: metadata.label, title: metadata.title, applicable: selectedUnitIds.length > 0, selectedUnitIds, formationAssignments: evaluation.formationAssignments, score: evaluation.score, summary: highlights.join(' · '), highlights, tradeoff, metrics, unitReasons }; } function blockedPlan( planId: SortieRecommendationPlanId, orderId: CampaignSortieOrderId | undefined, blockedReason: string ): SortieRecommendationPlan { const metadata = planMetadata[planId]; return { id: planId, label: metadata.label, title: metadata.title, applicable: false, blockedReason, selectedUnitIds: [], formationAssignments: {}, score: 0, summary: blockedReason, highlights: [blockedReason], tradeoff: '출전 조건을 먼저 해결해야 추천안을 계산할 수 있습니다.', metrics: emptyMetrics(orderId), unitReasons: {} }; } function emptyMetrics(orderId?: CampaignSortieOrderId): SortieRecommendationMetrics { const strategy = getUnitStrategyCoverage([]); return { orderId, orderFit: 0, activeBondCount: 0, pursuitPairCount: 0, terrainScore: 0, terrainGrade: '미정', strategyCoverageCount: 0, strategyCoverageTotal: strategy.total, coveredStrategyLabels: [], missingStrategyLabels: strategy.missing.map((definition) => definition.label), roleCoverage: 0, recommendedClassCoverage: 0 }; } function candidateFit( planId: SortieRecommendationPlanId, candidate: NormalizedCandidate, context: SortieRecommendationContext ) { const unit = candidate.unit; const hpRatio = unit.hp / Math.max(1, unit.maxHp); const strategyCount = getUnitStrategyNames([unit.id]).length; const recommendedBonus = recommendationRank(unit.id, context) < Number.MAX_SAFE_INTEGER ? 20 : 0; if (planId === 'resonance') { const relatedBonds = (context.bonds ?? []).filter((bond) => bond.unitIds.includes(unit.id)); const coreBonus = context.coreBondId && relatedBonds.some((bond) => bond.id === context.coreBondId) ? 45 : 0; return relatedBonds.reduce((total, bond) => total + bond.level, 0) + coreBonus + unit.stats.leadership * 0.25; } if (planId === 'terrain-strategy') { return candidate.terrainScore * 0.65 + strategyCount * 18 + unit.stats.intelligence * 0.2 + recommendedBonus; } if (context.orderId === 'mobile') { return unit.stats.agility * 0.5 + unit.move * 8 + unit.stats.might * 0.2 + candidate.terrainScore * 0.15 + recommendedBonus; } if (context.orderId === 'siege') { return unit.stats.leadership * 0.3 + unit.stats.intelligence * 0.3 + unit.attack * 0.8 + strategyCount * 12 + recommendedBonus; } return hpRatio * 35 + unit.stats.leadership * 0.35 + unit.stats.might * 0.2 + unit.attack * 0.65 + recommendedBonus; } function orderSupportScore( orderId: CampaignSortieOrderId | undefined, values: Record<'survival' | 'power' | 'mobility' | 'flankPower' | 'siegePower' | 'terrainFit' | 'strategyFit' | 'roleFit' | 'classFit' | 'unitFit' | 'synergyFit', number> ) { if (orderId === 'mobile') { return values.mobility * 0.3 + values.flankPower * 0.25 + values.terrainFit * 0.15 + values.strategyFit * 0.1 + values.roleFit * 0.1 + values.unitFit * 0.05 + values.synergyFit * 0.05; } if (orderId === 'siege') { return values.siegePower * 0.3 + values.terrainFit * 0.2 + values.strategyFit * 0.2 + values.survival * 0.15 + values.classFit * 0.1 + values.synergyFit * 0.05; } if (orderId === 'elite') { return values.survival * 0.35 + values.power * 0.2 + values.roleFit * 0.15 + values.terrainFit * 0.1 + values.strategyFit * 0.1 + values.unitFit * 0.05 + values.synergyFit * 0.05; } return values.survival * 0.25 + values.roleFit * 0.2 + values.unitFit * 0.2 + values.terrainFit * 0.15 + values.strategyFit * 0.1 + values.synergyFit * 0.1; } function recommendationRank(unitId: string, context: SortieRecommendationContext) { const index = (context.recommendedUnits ?? []).findIndex((entry) => entry.unitId === unitId); return index >= 0 ? index : Number.MAX_SAFE_INTEGER; } function unitRecommendationCoverage( selectedUnitIds: readonly string[], candidates: readonly NormalizedCandidate[], recommendations: readonly SortieRecommendationEntry[] ) { const available = new Set(candidates.map((candidate) => candidate.unit.id)); const relevant = unique(recommendations.map((entry) => entry.unitId)).filter((unitId) => available.has(unitId)); if (relevant.length === 0) { return 100; } const selected = new Set(selectedUnitIds); return (relevant.filter((unitId) => selected.has(unitId)).length / relevant.length) * 100; } function classCoverage( selected: readonly NormalizedCandidate[], recommendations: readonly SortieRecommendationClassEntry[] ) { if (recommendations.length === 0) { return 100; } const covered = recommendations.filter((entry) => selected.some((candidate) => entry.classKeys.includes(candidate.unit.classKey))); return (covered.length / recommendations.length) * 100; } function roleCounts(candidates: readonly NormalizedCandidate[], assignments: SortieFormationAssignments) { return candidates.reduce((counts, candidate) => { counts[assignments[candidate.unit.id] ?? 'reserve'] += 1; return counts; }, { front: 0, flank: 0, support: 0, reserve: 0 } as Record); } function roleAffinity(candidate: NormalizedCandidate, role: SortieFormationRole) { const unit = candidate.unit; if (role === 'front') { return unit.stats.might * 0.35 + unit.stats.leadership * 0.4 + (unit.hp / Math.max(1, unit.maxHp)) * 25; } if (role === 'flank') { return unit.stats.agility * 0.5 + unit.move * 7 + unit.stats.might * 0.2; } if (role === 'support') { return unit.stats.intelligence * 0.55 + unit.stats.leadership * 0.2 + getUnitStrategyNames([unit.id]).length * 14; } return 0; } function defaultFormationRole(classKey: UnitClassKey): SortieFormationRole { if (classKey === 'cavalry') { return 'flank'; } if (classKey === 'archer' || classKey === 'strategist' || classKey === 'quartermaster') { return 'support'; } if (classKey === 'lord' || classKey === 'infantry' || classKey === 'spearman') { return 'front'; } return 'reserve'; } function formationRoleLabel(role: SortieFormationRole | undefined) { return role === 'front' ? '전열' : role === 'flank' ? '돌파' : role === 'support' ? '후원' : '예비'; } function compareCandidates(left: NormalizedCandidate, right: NormalizedCandidate) { return left.rosterIndex - right.rosterIndex || left.unit.id.localeCompare(right.unit.id); } function unique(values: readonly string[]) { return [...new Set(values)]; } function average(values: readonly number[]) { return values.length > 0 ? values.reduce((total, value) => total + value, 0) / values.length : 0; } function clamp(value: number, minimum = 0, maximum = 100) { return Math.min(maximum, Math.max(minimum, finiteNumber(value))); } function round(value: number) { return Math.round(finiteNumber(value)); } function finiteInteger(value: number | undefined, fallback = 0) { return Math.floor(finiteNumber(value, fallback)); } function finiteNumber(value: number | undefined, fallback = 0) { return Number.isFinite(value) ? Number(value) : fallback; }