683 lines
26 KiB
TypeScript
683 lines
26 KiB
TypeScript
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<string, string>;
|
|
};
|
|
|
|
type NormalizedCandidate = SortieRecommendationCandidate & {
|
|
rosterIndex: number;
|
|
terrainScore: number;
|
|
preferredRole: SortieFormationRole;
|
|
};
|
|
|
|
type PartyEvaluation = {
|
|
score: number;
|
|
metrics: SortieRecommendationMetrics;
|
|
formationAssignments: SortieFormationAssignments;
|
|
};
|
|
|
|
const planMetadata: Record<SortieRecommendationPlanId, { label: string; title: string }> = {
|
|
order: { label: '군령 달성형', title: '군령 조건부터 맞추는 안정안' },
|
|
resonance: { label: '공명·추격형', title: '인연과 연계 공격을 잇는 공격안' },
|
|
'terrain-strategy': { label: '지형·전법형', title: '전장 적성과 전법 폭을 넓히는 대응안' }
|
|
};
|
|
|
|
const orderLabels: Record<CampaignSortieOrderId, string> = {
|
|
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<string>();
|
|
const candidates = context.candidates
|
|
.map<NormalizedCandidate>((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<SortieFormationAssignments>((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<string, NormalizedCandidate>,
|
|
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<SortieFormationRole, number>);
|
|
}
|
|
|
|
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;
|
|
}
|