feat: add tactical sortie recommendations

This commit is contained in:
2026-07-13 06:47:08 +09:00
parent a5e3af3421
commit 26a4d78185
5 changed files with 1789 additions and 14 deletions

View File

@@ -20,6 +20,7 @@
"verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs",
"verify:battle-save": "node scripts/verify-battle-save-normalization.mjs",
"verify:sortie-synergy": "node scripts/verify-sortie-synergy.mjs",
"verify:sortie-recommendations": "node scripts/verify-sortie-recommendations.mjs",
"verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs",
"verify:camp-skins": "node scripts/verify-camp-skin-data.mjs",
"verify:camp-soundscapes": "node scripts/verify-camp-soundscape-data.mjs",

View File

@@ -0,0 +1,439 @@
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');
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,
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);
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, 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)}`
);
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 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);
}
}

View File

@@ -5,6 +5,7 @@ const checks = [
'scripts/verify-campaign-save-normalization.mjs',
'scripts/verify-battle-save-normalization.mjs',
'scripts/verify-sortie-synergy.mjs',
'scripts/verify-sortie-recommendations.mjs',
'scripts/verify-battle-scenario-data.mjs',
'scripts/verify-camp-reward-data.mjs',
'scripts/verify-camp-skin-data.mjs',

View File

@@ -0,0 +1,682 @@
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;
}

View File

@@ -65,6 +65,11 @@ import {
type CampaignSortieOrderResultSnapshot,
type SortieOrderHistorySummary
} from '../data/sortieOrders';
import {
buildSortieRecommendationPlans,
type SortieRecommendationPlan,
type SortieRecommendationPlanId
} from '../data/sortieRecommendations';
import {
caoBreakRecruitBonds,
caoBreakRecruitUnits,
@@ -283,7 +288,7 @@ type SortieChecklistItem = {
};
type SortiePrepStep = 'briefing' | 'formation' | 'loadout';
type SortieFormationPanelMode = 'roster' | 'preset' | 'order';
type SortieFormationPanelMode = 'roster' | 'recommendation' | 'preset' | 'order';
const firstSortiePrepSteps: { id: SortiePrepStep; number: string; label: string; hint: string }[] = [
{ id: 'briefing', number: '01', label: '전장 파악', hint: '목표와 위험 확인' },
@@ -424,9 +429,10 @@ type SortieComparisonMetric = {
};
type SortieFormationComparisonPreview = {
source: 'focus' | 'hover-add' | 'hover-swap' | 'pinned-swap' | 'hover-blocked' | 'hover-preset' | 'pinned-preset';
mode: SortieUnitSynergyPreview['mode'] | 'swap' | 'preset';
source: 'focus' | 'hover-add' | 'hover-swap' | 'pinned-swap' | 'hover-blocked' | 'hover-recommendation' | 'pinned-recommendation' | 'hover-preset' | 'pinned-preset';
mode: SortieUnitSynergyPreview['mode'] | 'swap' | 'recommendation' | 'preset';
unitId: string;
recommendationId?: SortieRecommendationPlanId;
presetId?: CampaignSortiePresetId;
incomingUnitId: string | null;
incomingUnitName: string | null;
@@ -524,6 +530,25 @@ type SortiePresetProjection = {
omittedUnitNames: string[];
};
type SortieRecommendationProjection = {
recommendationId: SortieRecommendationPlanId;
plan: SortieRecommendationPlan;
label: string;
summary: string;
accent: number;
selectedUnitIds: string[];
formationAssignments: SortieFormationAssignments;
itemAssignments: CampaignSortieItemAssignments;
reasonLines: string[];
};
type SortieRecommendationUndoState = {
recommendationId: SortieRecommendationPlanId;
beforeCoreBondId?: string;
before: SortieConfigurationSnapshot;
applied: SortieConfigurationSnapshot;
};
type SortiePresetUndoState = {
presetId: CampaignSortiePresetId;
before: SortieConfigurationSnapshot;
@@ -541,6 +566,17 @@ type SortiePresetBrowserView = {
cards: Partial<Record<CampaignSortiePresetId, SortiePresetCardView>>;
};
type SortieRecommendationCardView = {
background: Phaser.GameObjects.Rectangle;
compareButton: Phaser.GameObjects.Rectangle;
};
type SortieRecommendationBrowserView = {
background: Phaser.GameObjects.Rectangle;
closeButton: Phaser.GameObjects.Rectangle;
cards: Partial<Record<SortieRecommendationPlanId, SortieRecommendationCardView>>;
};
type SortieOrderBrowserView = {
closeButton: Phaser.GameObjects.Rectangle;
cards: Partial<Record<CampaignSortieOrderId, Phaser.GameObjects.Rectangle>>;
@@ -610,10 +646,11 @@ type ReportFormationHistoryView = {
};
type SortieComparisonAction = {
kind: 'confirm-swap' | 'undo-swap' | 'confirm-preset' | 'undo-preset';
kind: 'confirm-swap' | 'undo-swap' | 'confirm-recommendation' | 'undo-recommendation' | 'confirm-preset' | 'undo-preset';
label: string;
outgoingUnitId?: string;
incomingUnitId?: string;
recommendationId?: SortieRecommendationPlanId;
presetId?: CampaignSortiePresetId;
};
@@ -927,6 +964,17 @@ const sortiePresetDefinitions: {
{ id: 'siege', label: '공성', summary: '전열·원거리 중심', accent: 0xc87552 }
];
const sortieRecommendationDefinitions: {
id: SortieRecommendationPlanId;
label: string;
summary: string;
accent: number;
}[] = [
{ id: 'order', label: '군령 달성형', summary: '선택 군령의 달성 조건 우선', accent: 0xd8b15f },
{ id: 'resonance', label: '공명·추격형', summary: '활성 공명과 추격 연계 우선', accent: 0x8f78cf },
{ id: 'terrain-strategy', label: '지형·전법형', summary: '지형 적성과 전법 범위 우선', accent: 0x72a7d8 }
];
const reportFormationHistoryPageSize = 7;
const sortiePortraitRosterPageSize = 6;
@@ -11121,6 +11169,12 @@ export class CampScene extends Phaser.Scene {
private sortiePinnedSwapCandidateUnitId?: string;
private sortieSwapUndoState?: SortieSwapUndoState;
private sortieFormationPanelMode: SortieFormationPanelMode = 'roster';
private sortieHoveredRecommendationId?: SortieRecommendationPlanId;
private sortiePinnedRecommendationId?: SortieRecommendationPlanId;
private sortieRecommendationUndoState?: SortieRecommendationUndoState;
private sortieRecommendationPlanCache?: { key: string; plans: SortieRecommendationPlan[] };
private sortieRecommendationBrowserView?: SortieRecommendationBrowserView;
private sortieRecommendationToggleButton?: Phaser.GameObjects.Rectangle;
private sortieHoveredPresetId?: CampaignSortiePresetId;
private sortiePinnedPresetId?: CampaignSortiePresetId;
private sortiePresetOverwriteConfirmId?: CampaignSortiePresetId;
@@ -11171,6 +11225,12 @@ export class CampScene extends Phaser.Scene {
this.sortiePinnedSwapCandidateUnitId = undefined;
this.sortieSwapUndoState = undefined;
this.sortieFormationPanelMode = 'roster';
this.sortieHoveredRecommendationId = undefined;
this.sortiePinnedRecommendationId = undefined;
this.sortieRecommendationUndoState = undefined;
this.sortieRecommendationPlanCache = undefined;
this.sortieRecommendationBrowserView = undefined;
this.sortieRecommendationToggleButton = undefined;
this.sortieHoveredPresetId = undefined;
this.sortiePinnedPresetId = undefined;
this.sortiePresetOverwriteConfirmId = undefined;
@@ -12662,6 +12722,8 @@ export class CampScene extends Phaser.Scene {
const returnToSortiePrep = this.sortieObjects.length > 0;
this.sortieSwapUndoState = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
this.sortieRecommendationUndoState = undefined;
this.closeSortieRecommendationBrowserState();
this.sortiePresetUndoState = undefined;
this.closeSortiePresetBrowserState();
this.hideCampSaveSlotPanel();
@@ -13214,6 +13276,8 @@ export class CampScene extends Phaser.Scene {
const planWidth = width - rosterWidth - gap;
if (this.sortieOrderBrowserOpen) {
this.renderCampaignSortieOrderBrowser(x, y, rosterWidth, height, depth);
} else if (this.sortieRecommendationBrowserOpen) {
this.renderCampaignSortieRecommendationBrowser(x, y, rosterWidth, height, depth);
} else if (this.sortiePresetBrowserOpen) {
this.renderCampaignSortiePresetBrowser(x, y, rosterWidth, height, depth);
} else {
@@ -13459,6 +13523,135 @@ export class CampScene extends Phaser.Scene {
};
}
private renderCampaignSortieRecommendationBrowser(x: number, y: number, width: number, height: number, depth: number) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.98));
bg.setOrigin(0);
bg.setDepth(depth);
bg.setStrokeStyle(1, palette.gold, 0.68);
this.trackSortie(this.add.text(x + 18, y + 13, '출전 전략 추천', this.textStyle(19, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(
this.add.text(x + 166, y + 18, '군령·공명·지형 기준 3안', this.textStyle(11, '#9fb0bf', true))
).setDepth(depth + 1);
const closeButton = this.trackSortie(this.add.rectangle(x + width - 96, y + 9, 78, 30, 0x18232e, 0.96));
closeButton.setOrigin(0);
closeButton.setDepth(depth + 2);
closeButton.setStrokeStyle(1, palette.blue, 0.58);
closeButton.setInteractive({ useHandCursor: true });
closeButton.on('pointerover', () => closeButton.setFillStyle(0x243442, 1).setStrokeStyle(1, palette.gold, 0.9));
closeButton.on('pointerout', () => closeButton.setFillStyle(0x18232e, 0.96).setStrokeStyle(1, palette.blue, 0.58));
closeButton.on('pointerdown', () => this.toggleSortieRecommendationBrowser(false));
this.trackSortie(this.add.text(x + width - 57, y + 24, '무장 목록', this.textStyle(11, '#d8ecff', true)))
.setOrigin(0.5)
.setDepth(depth + 3);
const cards: SortieRecommendationBrowserView['cards'] = {};
const recommendationPlans = new Map(this.sortieRecommendationPlans().map((plan) => [plan.id, plan]));
const plans = new Map(this.sortieRecommendationProjections().map((plan) => [plan.recommendationId, plan]));
const cardX = x + 18;
const cardWidth = width - 36;
const cardHeight = 104;
const cardGap = 8;
sortieRecommendationDefinitions.forEach((definition, index) => {
const cardY = y + 50 + index * (cardHeight + cardGap);
const recommendationPlan = recommendationPlans.get(definition.id);
const plan = plans.get(definition.id);
const pending = this.sortiePinnedRecommendationId === definition.id;
const hovered = this.sortieHoveredRecommendationId === definition.id;
const current = Boolean(plan && this.sortieRecommendationProjectionMatchesCurrent(plan));
const cardFill = pending ? 0x263229 : current ? 0x1d3028 : plan ? 0x151f2a : 0x111820;
const cardStroke = pending || hovered ? palette.gold : current ? palette.green : plan ? definition.accent : 0x53606c;
const card = this.trackSortie(this.add.rectangle(cardX, cardY, cardWidth, cardHeight, cardFill, plan ? 0.98 : 0.82));
card.setOrigin(0);
card.setDepth(depth + 1);
card.setStrokeStyle(pending || hovered ? 2 : 1, cardStroke, pending || hovered ? 0.96 : 0.52);
card.setInteractive({ useHandCursor: Boolean(plan) });
card.on('pointerover', () => {
if (!plan) {
return;
}
this.sortieHoveredRecommendationId = definition.id;
card.setFillStyle(0x22302d, 1).setStrokeStyle(2, palette.gold, 0.96);
this.refreshCampaignSortieComparisonPanel();
});
card.on('pointerout', () => {
if (this.sortieHoveredRecommendationId === definition.id) {
this.sortieHoveredRecommendationId = undefined;
}
card.setFillStyle(cardFill, plan ? 0.98 : 0.82).setStrokeStyle(pending ? 2 : 1, cardStroke, pending ? 0.96 : 0.52);
this.refreshCampaignSortieComparisonPanel();
});
card.on('pointerdown', () => {
if (plan) {
this.pinSortieRecommendation(definition.id);
}
});
const accent = this.trackSortie(this.add.rectangle(cardX + 4, cardY + 4, 5, cardHeight - 8, definition.accent, plan ? 0.94 : 0.42));
accent.setOrigin(0);
accent.setDepth(depth + 2);
this.trackSortie(this.add.text(cardX + 22, cardY + 11, definition.label, this.textStyle(17, plan ? '#f2e3bf' : '#a6afb9', true))).setDepth(depth + 2);
this.trackSortie(
this.add.text(
cardX + 142,
cardY + 16,
this.compactText(recommendationPlan?.summary ?? definition.summary, 34),
this.textStyle(10, '#9fb0bf', true)
)
).setDepth(depth + 2);
const badge = current ? '현재 적용' : pending ? '비교 중' : '';
if (badge) {
this.trackSortie(this.add.text(cardX + cardWidth - 16, cardY + 13, badge, this.textStyle(10, current ? '#a8ffd0' : '#ffdf7b', true)))
.setOrigin(1, 0)
.setDepth(depth + 2);
}
const memberLine = plan
? this.sortiePresetMemberLine(plan.selectedUnitIds.map((unitId) => this.unitName(unitId)))
: recommendationPlan?.blockedReason ?? '추천안을 계산할 수 없습니다.';
this.trackSortie(this.add.text(cardX + 22, cardY + 39, this.compactText(memberLine, 54), this.textStyle(12, plan ? '#d4dce6' : '#77818c', Boolean(plan))))
.setDepth(depth + 2);
const snapshot = plan
? this.sortieSynergySnapshot(plan.selectedUnitIds, this.nextSortieScenario(), plan.formationAssignments)
: undefined;
const pursuit = plan
? this.sortiePursuitPairs(plan.selectedUnitIds, this.nextSortieScenario(), plan.formationAssignments)
: undefined;
const strategyCoverage = plan ? this.sortieStrategyCoverage(plan.selectedUnitIds) : undefined;
const statusLine = snapshot && pursuit && strategyCoverage
? `출전 ${plan?.selectedUnitIds.length ?? 0}명 · 역할 ${snapshot.coveredRoleCount}/3 · 공명 ${snapshot.activeBondCount} · 추격 ${pursuit.activePairs.length} · 전법 ${strategyCoverage.count}/${strategyCoverage.total} · 지형 ${snapshot.terrainGrade}`
: recommendationPlan?.blockedReason ?? '필수·출전 제한을 만족하는 추천안이 없습니다.';
this.trackSortie(this.add.text(cardX + 22, cardY + 62, this.compactText(statusLine, 65), this.textStyle(10, snapshot ? '#d8b15f' : '#7f8994', true)))
.setDepth(depth + 2);
const compareEnabled = Boolean(plan && !current);
const compareButton = this.renderSortiePresetBrowserButton(
current ? '적용 중' : pending ? '비교 고정' : '비교',
cardX + cardWidth - 94,
cardY + 75,
78,
21,
compareEnabled,
pending,
() => this.pinSortieRecommendation(definition.id),
depth + 3
);
cards[definition.id] = { background: card, compareButton };
});
this.trackSortie(
this.add.text(
x + 18,
y + height - 24,
'카드 호버 → 오른쪽 장단점 비교 → 비교 고정 → 적용 확정',
this.textStyle(10, '#9fb0bf', true)
)
).setDepth(depth + 1);
this.sortieRecommendationBrowserView = { background: bg, closeButton, cards };
}
private renderCampaignSortieOrderBrowser(x: number, y: number, width: number, height: number, depth: number) {
const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.98));
bg.setOrigin(0);
@@ -13764,7 +13957,7 @@ export class CampScene extends Phaser.Scene {
actionButton.on('pointerout', () => {
const action = this.sortieComparisonAction();
if (actionButton.visible && action) {
const undo = action.kind === 'undo-swap' || action.kind === 'undo-preset';
const undo = action.kind === 'undo-swap' || action.kind === 'undo-recommendation' || action.kind === 'undo-preset';
actionButton
.setFillStyle(undo ? 0x243442 : 0x263a2d, 0.98)
.setStrokeStyle(1, undo ? palette.blue : palette.green, 0.78);
@@ -13879,7 +14072,7 @@ export class CampScene extends Phaser.Scene {
const preview = this.sortieCoreResonanceSelectorOpen ? undefined : this.sortieFormationComparisonPreview();
const action = this.sortieCoreResonanceSelectorOpen ? undefined : this.sortieComparisonAction(preview);
this.refreshSortieComparisonActionButton(view, action);
const isUndoAction = action?.kind === 'undo-swap' || action?.kind === 'undo-preset';
const isUndoAction = action?.kind === 'undo-swap' || action?.kind === 'undo-recommendation' || action?.kind === 'undo-preset';
const current = preview?.current ?? this.sortieSynergySnapshot();
const projected = isUndoAction ? current : preview?.projected ?? current;
const showMetricTransition = !isUndoAction && Boolean(preview?.projected);
@@ -13893,6 +14086,7 @@ export class CampScene extends Phaser.Scene {
const strategyCoverageDelta = strategyCoverageComparison.delta;
this.setCampaignSortieCoreSelectorVisible(false);
const isHoverPreview = preview?.source === 'hover-add' || preview?.source === 'hover-swap' || preview?.source === 'pinned-swap' ||
preview?.source === 'hover-recommendation' || preview?.source === 'pinned-recommendation' ||
preview?.source === 'hover-preset' || preview?.source === 'pinned-preset';
const lostRole = Boolean(comparison?.lostRoles.length);
const lostStrategyCoverage = strategyCoverageDelta < 0;
@@ -13960,10 +14154,14 @@ export class CampScene extends Phaser.Scene {
return;
}
const title = action?.kind === 'undo-preset' && this.sortiePresetUndoState
const title = action?.kind === 'undo-recommendation' && this.sortieRecommendationUndoState
? `${this.sortieRecommendationDefinition(this.sortieRecommendationUndoState.recommendationId).label} 적용 완료`
: action?.kind === 'undo-preset' && this.sortiePresetUndoState
? `${this.sortiePresetDefinition(this.sortiePresetUndoState.presetId).label} 적용 완료`
: isUndoAction && this.sortieSwapUndoState
? `교체 완료 · ${this.sortieSwapUndoState.outgoingUnitName}${this.sortieSwapUndoState.incomingUnitName}`
: preview.source === 'pinned-recommendation' || preview.source === 'hover-recommendation'
? `${this.sortieRecommendationDefinition(preview.recommendationId ?? 'order').label} 비교`
: preview.source === 'pinned-preset' || preview.source === 'hover-preset'
? `${this.sortiePresetDefinition(preview.presetId ?? 'elite').label} 편성책 비교`
: preview.source === 'pinned-swap'
@@ -13981,6 +14179,7 @@ export class CampScene extends Phaser.Scene {
)
);
const headlineColor = isUndoAction || preview.source === 'hover-swap' || preview.source === 'pinned-swap' ||
preview.source === 'hover-recommendation' || preview.source === 'pinned-recommendation' ||
preview.source === 'hover-preset' || preview.source === 'pinned-preset' || preview.mode === 'add'
? '#a8ffd0'
: preview.mode === 'remove'
@@ -13990,12 +14189,14 @@ export class CampScene extends Phaser.Scene {
: preview.mode === 'blocked'
? '#87919c'
: '#d4dce6';
const headline = action?.kind === 'undo-preset' && this.sortiePresetUndoState
const headline = action?.kind === 'undo-recommendation' && this.sortieRecommendationUndoState
? `추천 명단 ${current.selectedUnitIds.length}명·역할 적용 · 장비·보급 재점검`
: action?.kind === 'undo-preset' && this.sortiePresetUndoState
? `명단 ${current.selectedUnitIds.length}명·역할 저장값 적용 · 장비·보급 재점검`
: isUndoAction && this.sortieSwapUndoState
? `${this.sortieSwapUndoState.incomingUnitName} 편성 완료 · 장비·보급 재점검`
: preview.headline;
view.headline.setText(this.compactText(headline, action ? 44 : 64)).setColor(headlineColor);
view.headline.setText(this.compactText(headline, action ? 34 : 64)).setColor(headlineColor);
view.metrics.forEach((metricView, index) => {
const metric = preview.metrics[index];
@@ -14017,6 +14218,16 @@ export class CampScene extends Phaser.Scene {
.setColor(color);
});
if (action?.kind === 'undo-recommendation' && this.sortieRecommendationUndoState) {
view.impact
.setText(`현재 전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total} · 역할 ${current.coveredRoleCount}/3 · 추격 ${pursuitChanges.currentPairs.length}조 · 지형 ${current.terrainGrade}`)
.setColor('#a8ffd0');
view.detail
.setText('공통 무장 보급만 보존했습니다. 직전 추천안 적용은 한 번 되돌릴 수 있습니다.')
.setColor('#ffdf7b');
return;
}
if (action?.kind === 'undo-preset' && this.sortiePresetUndoState) {
view.impact
.setText(`현재 전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total} · 역할 ${current.coveredRoleCount}/3 · 추격 ${pursuitChanges.currentPairs.length}조 · 지형 ${current.terrainGrade}`)
@@ -14047,6 +14258,26 @@ export class CampScene extends Phaser.Scene {
return;
}
if (preview.mode === 'recommendation' && preview.recommendationId) {
const recommendation = this.sortieRecommendationProjection(preview.recommendationId);
const pursuitDelta = pursuitChanges.projectedPairs.length - pursuitChanges.currentPairs.length;
const strategyDelta = projectedStrategyCoverage.count - currentStrategyCoverage.count;
const highlightLine = recommendation?.plan.highlights[0] || preview.detail;
const tradeoffLine = recommendation?.plan.tradeoff || '현재 편성과 비교해 역할과 보급을 다시 확인하십시오.';
view.impact
.setText(this.compactText(highlightLine, 42))
.setColor('#a8ffd0');
view.detail
.setText(
this.compactText(
`${tradeoffLine} · 전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total}${projectedStrategyCoverage.count}/${projectedStrategyCoverage.total} (${strategyDelta > 0 ? '+' : ''}${strategyDelta}) · 추격 ${pursuitChanges.currentPairs.length}${pursuitChanges.projectedPairs.length}조 (${pursuitDelta > 0 ? '+' : ''}${pursuitDelta}) · 실제 편성 미변경`,
52
)
)
.setColor('#ffdf7b');
return;
}
const gainedRoleLabels = comparison?.gainedRoles.map((role) => this.sortieFormationRoleLabel(role)) ?? [];
const lostRoleLabels = comparison?.lostRoles.map((role) => this.sortieFormationRoleLabel(role)) ?? [];
const roleChanges = [
@@ -14098,7 +14329,7 @@ export class CampScene extends Phaser.Scene {
return;
}
const undo = action.kind === 'undo-swap' || action.kind === 'undo-preset';
const undo = action.kind === 'undo-swap' || action.kind === 'undo-recommendation' || action.kind === 'undo-preset';
view.actionButton
.setInteractive({ useHandCursor: true })
.setFillStyle(undo ? 0x243442 : 0x263a2d, 0.98)
@@ -14109,6 +14340,19 @@ export class CampScene extends Phaser.Scene {
private sortieComparisonAction(
preview = this.sortieFormationComparisonPreview()
): SortieComparisonAction | undefined {
if (
preview?.source === 'pinned-recommendation' &&
preview.mode === 'recommendation' &&
preview.recommendationId &&
this.sortieRecommendationProjection(preview.recommendationId)
) {
return {
kind: 'confirm-recommendation',
label: '적용 확정',
recommendationId: preview.recommendationId
};
}
if (
preview?.source === 'pinned-preset' &&
preview.mode === 'preset' &&
@@ -14140,11 +14384,20 @@ export class CampScene extends Phaser.Scene {
if (
preview &&
(preview.source === 'hover-add' || preview.source === 'hover-swap' ||
preview.source === 'hover-blocked' || preview.source === 'hover-preset')
preview.source === 'hover-blocked' || preview.source === 'hover-recommendation' || preview.source === 'hover-preset')
) {
return undefined;
}
const recommendationUndo = this.sortieRecommendationUndoState;
if (recommendationUndo && this.sortiePersistedConfigurationMatches(recommendationUndo.applied)) {
return {
kind: 'undo-recommendation',
label: '되돌리기',
recommendationId: recommendationUndo.recommendationId
};
}
const presetUndo = this.sortiePresetUndoState;
if (presetUndo && this.sortiePersistedConfigurationMatches(presetUndo.applied)) {
return {
@@ -14168,6 +14421,10 @@ export class CampScene extends Phaser.Scene {
private handleSortieComparisonAction() {
const action = this.sortieComparisonAction();
if (action?.kind === 'confirm-recommendation' && action.recommendationId) {
this.confirmPinnedSortieRecommendation(action.recommendationId);
return;
}
if (action?.kind === 'confirm-preset' && action.presetId) {
this.confirmPinnedSortiePreset(action.presetId);
return;
@@ -14182,6 +14439,10 @@ export class CampScene extends Phaser.Scene {
}
if (action?.kind === 'undo-preset') {
this.undoLastSortiePreset();
return;
}
if (action?.kind === 'undo-recommendation') {
this.undoLastSortieRecommendation();
}
}
@@ -14980,9 +15241,20 @@ export class CampScene extends Phaser.Scene {
const canUsePresetBook = this.usesStagedSortiePrep() && this.sortiePrepStep === 'formation' && !this.isFirstSortiePrepSlice();
const canUseSortieOrder = this.usesStagedSortiePrep() && this.sortiePrepStep === 'formation' && hasBattle;
const canRecommend = this.canApplyRecommendedSortiePlan(scenario) && this.sortieFormationPanelMode === 'roster';
const canBrowseRecommendations = this.canApplyRecommendedSortiePlan(scenario);
this.trackSortie(this.add.text(x + 18, y + 14, hasBattle ? '출전 슬롯' : '동행 명단', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1);
if (canUsePresetBook && canUseSortieOrder) {
this.renderSortiePanelButton('추천', x + width - 230, y + 10, 48, 22, canRecommend, false, () => this.applyRecommendedSortiePlan(), depth + 1);
this.sortieRecommendationToggleButton = this.renderSortiePanelButton(
'추천안',
x + width - 230,
y + 10,
48,
22,
canBrowseRecommendations,
this.sortieRecommendationBrowserOpen,
() => this.toggleSortieRecommendationBrowser(),
depth + 1
);
this.sortiePresetToggleButton = this.renderSortiePanelButton(
'편성책',
x + width - 176,
@@ -15007,6 +15279,7 @@ export class CampScene extends Phaser.Scene {
depth + 1
);
} else {
this.sortieRecommendationToggleButton = undefined;
this.sortiePresetToggleButton = undefined;
this.sortieOrderToggleButton = undefined;
this.renderSortiePanelButton(hasBattle ? '추천 편성' : '추천 동행', x + width - 104, y + 10, 86, 22, canRecommend, false, () => this.applyRecommendedSortiePlan(), depth + 1);
@@ -15674,6 +15947,117 @@ export class CampScene extends Phaser.Scene {
return this.campaign?.sortieFormationPresets ?? getCampaignState().sortieFormationPresets;
}
private sortieRecommendationDefinition(recommendationId: SortieRecommendationPlanId) {
return sortieRecommendationDefinitions.find((definition) => definition.id === recommendationId) ?? sortieRecommendationDefinitions[0];
}
private sortieRecommendationPlans(scenario = this.nextSortieScenario()) {
if (!scenario) {
return [];
}
const rule = this.nextSortieRule(scenario);
const recommendationById = new Map(rule.recommended.map((entry) => [entry.unitId, entry]));
const candidates = this.sortieRosterUnits().map((unit, rosterIndex) => {
const availability = this.sortieUnitAvailability(unit, scenario, rule);
return {
unit,
available: availability.available,
blockedReason: availability.available ? undefined : availability.reason,
terrainScore: this.sortieTerrainScore(unit, scenario),
preferredRole: recommendationById.get(unit.id)?.role ?? this.defaultSortieFormationRole(unit),
rosterIndex
};
});
const bonds = this.sortieSynergyBonds(scenario);
const cacheKey = JSON.stringify({
battleId: scenario.id,
maxUnits: this.sortieMaxUnits(scenario),
orderId: this.currentSortieOrderId(),
coreBondId: this.currentSortieResonanceBondId(scenario),
candidates: candidates.map((candidate) => ({
id: candidate.unit.id,
available: candidate.available,
hp: candidate.unit.hp,
maxHp: candidate.unit.maxHp,
attack: candidate.unit.attack,
move: candidate.unit.move,
stats: candidate.unit.stats,
terrainScore: candidate.terrainScore,
preferredRole: candidate.preferredRole,
rosterIndex: candidate.rosterIndex
})),
bonds: bonds.map((bond) => ({ id: bond.id, unitIds: bond.unitIds, level: bond.level, exp: bond.exp }))
});
if (this.sortieRecommendationPlanCache?.key === cacheKey) {
return this.sortieRecommendationPlanCache.plans;
}
const plans = buildSortieRecommendationPlans({
battleId: scenario.id,
maxUnits: this.sortieMaxUnits(scenario),
orderId: this.currentSortieOrderId(),
candidates,
requiredUnitIds: rule.requiredUnitIds ?? defaultRequiredSortieUnitIds,
excludedUnitIds: rule.excludedUnitIds ?? [],
recommendedUnits: rule.recommended,
recommendedClasses: rule.recommendedClasses ?? [],
bonds,
coreBondId: this.currentSortieResonanceBondId(scenario)
});
this.sortieRecommendationPlanCache = { key: cacheKey, plans };
return plans;
}
private sortieRecommendationProjections(scenario = this.nextSortieScenario()): SortieRecommendationProjection[] {
const selectedItemAssignments = this.cloneSortieItemAssignments(this.sortieItemAssignments);
return this.sortieRecommendationPlans(scenario).flatMap<SortieRecommendationProjection>((plan) => {
if (!plan.applicable || plan.selectedUnitIds.length === 0) {
return [];
}
const selectedUnitIds = this.normalizedSortieUnitIds(plan.selectedUnitIds);
const selectedIds = new Set(selectedUnitIds);
const formationAssignments = normalizeSortieFormationAssignments(plan.formationAssignments, selectedIds);
const itemAssignments = Object.entries(selectedItemAssignments).reduce<CampaignSortieItemAssignments>(
(assignments, [unitId, stocks]) => {
if (selectedIds.has(unitId)) {
assignments[unitId] = { ...stocks };
}
return assignments;
},
{}
);
const definition = this.sortieRecommendationDefinition(plan.id);
return [{
recommendationId: plan.id,
plan,
label: plan.label || definition.label,
summary: plan.summary || definition.summary,
accent: definition.accent,
selectedUnitIds,
formationAssignments,
itemAssignments,
reasonLines: [...plan.highlights.slice(0, 2), plan.tradeoff].filter(Boolean)
}];
});
}
private sortieRecommendationProjection(
recommendationId: SortieRecommendationPlanId,
scenario = this.nextSortieScenario()
) {
return this.sortieRecommendationProjections(scenario).find((projection) => projection.recommendationId === recommendationId);
}
private sortieRecommendationProjectionMatchesCurrent(projection: SortieRecommendationProjection) {
if (JSON.stringify(projection.selectedUnitIds) !== JSON.stringify(this.selectedSortieUnitIds)) {
return false;
}
const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit]));
return projection.selectedUnitIds.every((unitId) => {
const unit = rosterById.get(unitId);
return Boolean(unit && projection.formationAssignments[unitId] === this.sortieFormationRole(unit));
});
}
private sortiePresetDefinition(presetId: CampaignSortiePresetId) {
return sortiePresetDefinitions.find((definition) => definition.id === presetId) ?? sortiePresetDefinitions[0];
}
@@ -15786,7 +16170,7 @@ export class CampScene extends Phaser.Scene {
}
private sortiePresetComparisonMetrics(
projection: SortiePresetProjection,
projection: Pick<SortiePresetProjection, 'selectedUnitIds' | 'formationAssignments'>,
scenario = this.nextSortieScenario()
): SortieComparisonMetric[] {
const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit]));
@@ -15814,6 +16198,13 @@ export class CampScene extends Phaser.Scene {
}));
}
private sortieRecommendationComparisonMetrics(
projection: SortieRecommendationProjection,
scenario = this.nextSortieScenario()
) {
return this.sortiePresetComparisonMetrics(projection, scenario);
}
private sortieFormationComparisonPreview(): SortieFormationComparisonPreview | undefined {
const scenario = this.nextSortieScenario();
const selectedUnitIds = [...this.selectedSortieUnitIds];
@@ -15822,6 +16213,70 @@ export class CampScene extends Phaser.Scene {
if (this.sortieOrderBrowserOpen) {
return undefined;
}
const recommendationId = this.sortieHoveredRecommendationId ?? this.sortiePinnedRecommendationId;
if (recommendationId) {
const projection = this.sortieRecommendationProjection(recommendationId);
if (projection) {
const projected = this.sortieSynergySnapshot(
projection.selectedUnitIds,
scenario,
projection.formationAssignments
);
const comparison = compareSortieSynergy(current, projected);
const projectedIds = new Set(projection.selectedUnitIds);
const retainedCount = selectedUnitIds.filter((unitId) => projectedIds.has(unitId)).length;
const outgoingCount = selectedUnitIds.length - retainedCount;
const incomingCount = projection.selectedUnitIds.length - retainedCount;
const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit]));
const roleChangeCount = projection.selectedUnitIds.filter((unitId) => {
const unit = rosterById.get(unitId);
return Boolean(unit && selectedIds.has(unitId) && projection.formationAssignments[unitId] !== this.sortieFormationRole(unit, scenario));
}).length;
return {
source: this.sortiePinnedRecommendationId === recommendationId && !this.sortieHoveredRecommendationId
? 'pinned-recommendation'
: 'hover-recommendation',
mode: 'recommendation',
unitId: recommendationId,
recommendationId,
incomingUnitId: null,
incomingUnitName: null,
outgoingUnitId: null,
outgoingUnitName: null,
selectedUnitIds,
projectedSelectedUnitIds: projection.selectedUnitIds,
headline: `유지 ${retainedCount}명 · 교체 ${outgoingCount}OUT/${incomingCount}IN · 역할 ${roleChangeCount}명 변경 · ${incomingCount > 0 ? `신규 ${incomingCount}명 보급 미배정` : '보급 유지'}`,
detail: projection.reasonLines.join(' · ') || `${projection.label} 기준으로 계산한 출전안입니다.`,
metrics: this.sortieRecommendationComparisonMetrics(projection, scenario),
current,
projected,
comparison
};
}
}
const appliedRecommendation = this.sortieRecommendationUndoState;
if (appliedRecommendation && this.sortiePersistedConfigurationMatches(appliedRecommendation.applied)) {
const projection = this.sortieRecommendationProjection(appliedRecommendation.recommendationId);
if (projection) {
return {
source: 'focus',
mode: 'recommendation',
unitId: appliedRecommendation.recommendationId,
recommendationId: appliedRecommendation.recommendationId,
incomingUnitId: null,
incomingUnitName: null,
outgoingUnitId: null,
outgoingUnitName: null,
selectedUnitIds,
projectedSelectedUnitIds: selectedUnitIds,
headline: `${projection.label} 적용 완료`,
detail: '유지 가능한 보급만 보존했습니다.',
metrics: this.sortieRecommendationComparisonMetrics(projection, scenario),
current,
projected: current
};
}
}
const presetId = this.sortieHoveredPresetId ?? this.sortiePinnedPresetId;
if (presetId) {
const projection = this.sortiePresetProjection(presetId, scenario);
@@ -16057,6 +16512,7 @@ export class CampScene extends Phaser.Scene {
private toggleSortieOrderBrowser(forceOpen?: boolean) {
const open = forceOpen ?? !this.sortieOrderBrowserOpen;
if (open) {
this.closeSortieRecommendationBrowserState();
this.closeSortiePresetBrowserState();
this.sortieFormationPanelMode = 'order';
this.sortieHoveredUnitId = undefined;
@@ -16086,6 +16542,7 @@ export class CampScene extends Phaser.Scene {
if (!open) {
this.closeSortiePresetBrowserState();
} else {
this.closeSortieRecommendationBrowserState();
this.sortieFormationPanelMode = 'preset';
this.sortieHoveredUnitId = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
@@ -16106,10 +16563,41 @@ export class CampScene extends Phaser.Scene {
}
private closeSortieFormationPanelBrowser() {
this.closeSortieRecommendationBrowserState();
this.closeSortiePresetBrowserState();
this.sortieFormationPanelMode = 'roster';
}
private toggleSortieRecommendationBrowser(forceOpen?: boolean) {
const open = forceOpen ?? !this.sortieRecommendationBrowserOpen;
if (!open) {
this.closeSortieRecommendationBrowserState();
} else {
this.closeSortiePresetBrowserState();
this.sortieFormationPanelMode = 'recommendation';
this.sortieHoveredUnitId = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
this.sortieHoveredRecommendationId = undefined;
this.sortiePinnedRecommendationId = undefined;
this.sortieCoreResonanceSelectorOpen = false;
this.sortieCoreResonancePage = 0;
}
soundDirector.playSelect();
this.showSortiePrep();
}
private closeSortieRecommendationBrowserState() {
if (this.sortieFormationPanelMode === 'recommendation') {
this.sortieFormationPanelMode = 'roster';
}
this.sortieHoveredRecommendationId = undefined;
this.sortiePinnedRecommendationId = undefined;
}
private get sortieRecommendationBrowserOpen() {
return this.sortieFormationPanelMode === 'recommendation';
}
private get sortiePresetBrowserOpen() {
return this.sortieFormationPanelMode === 'preset';
}
@@ -16118,6 +16606,30 @@ export class CampScene extends Phaser.Scene {
return this.sortieFormationPanelMode === 'order';
}
private pinSortieRecommendation(recommendationId: SortieRecommendationPlanId) {
const projection = this.sortieRecommendationProjection(recommendationId);
const definition = this.sortieRecommendationDefinition(recommendationId);
if (!projection) {
this.sortiePinnedRecommendationId = undefined;
this.refreshCampaignSortieComparisonPanel();
this.showCampNotice(`${definition.label}을 계산할 수 없습니다.`);
return;
}
if (this.sortieRecommendationProjectionMatchesCurrent(projection)) {
this.sortiePinnedRecommendationId = undefined;
this.refreshCampaignSortieComparisonPanel();
this.showCampNotice(`${definition.label}이 이미 현재 명단과 역할에 적용되어 있습니다.`);
return;
}
this.sortiePinnedRecommendationId = recommendationId;
this.sortieHoveredRecommendationId = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
this.sortiePresetOverwriteConfirmId = undefined;
soundDirector.playSelect();
this.showSortiePrep();
}
private pinSortiePreset(presetId: CampaignSortiePresetId) {
const projection = this.sortiePresetProjection(presetId);
const definition = this.sortiePresetDefinition(presetId);
@@ -16187,6 +16699,82 @@ export class CampScene extends Phaser.Scene {
this.showCampNotice(`${definition.label} 편성책 저장 완료 · 보급은 포함하지 않았습니다.`);
}
private confirmPinnedSortieRecommendation(recommendationId: SortieRecommendationPlanId) {
const preview = this.sortieFormationComparisonPreview();
if (preview?.source !== 'pinned-recommendation' || preview.recommendationId !== recommendationId) {
this.sortiePinnedRecommendationId = undefined;
this.refreshCampaignSortieComparisonPanel();
this.showCampNotice('고정된 추천안이 없습니다. 비교 버튼을 먼저 누르십시오.');
return;
}
const projection = this.sortieRecommendationProjection(recommendationId);
if (
!projection ||
JSON.stringify(projection.selectedUnitIds) !== JSON.stringify(preview.projectedSelectedUnitIds)
) {
this.sortiePinnedRecommendationId = undefined;
this.refreshCampaignSortieComparisonPanel();
this.showCampNotice('편성 조건이 달라졌습니다. 추천안을 다시 비교하십시오.');
return;
}
const scenario = this.nextSortieScenario();
const beforeCoreBondId = this.currentSortieResonanceBondId(scenario);
const before = this.captureSortieConfiguration();
this.sortieRecommendationUndoState = undefined;
this.sortiePresetUndoState = undefined;
this.sortieSwapUndoState = undefined;
this.selectedSortieUnitIds = [...projection.selectedUnitIds];
this.sortieFormationAssignments = { ...projection.formationAssignments };
this.sortieItemAssignments = this.cloneSortieItemAssignments(projection.itemAssignments);
this.sortieFocusedUnitId = projection.selectedUnitIds.includes(this.sortieFocusedUnitId)
? this.sortieFocusedUnitId
: projection.selectedUnitIds[0];
this.sortieRosterScroll = 0;
this.sortiePortraitRosterPage = 0;
this.sortiePlanFeedback = `${projection.label} 적용 완료 · 장비·보급 재점검`;
this.persistSortieSelection();
this.sortieRecommendationUndoState = {
recommendationId,
beforeCoreBondId,
before,
applied: this.captureSortieConfiguration()
};
this.closeSortieRecommendationBrowserState();
soundDirector.playSelect();
this.showSortiePrep();
this.showCampNotice(`${projection.label} 적용 완료 · 공통 무장의 보급만 유지했습니다.`);
}
private undoLastSortieRecommendation() {
const undo = this.sortieRecommendationUndoState;
if (!undo || !this.sortiePersistedConfigurationMatches(undo.applied)) {
this.sortieRecommendationUndoState = undefined;
this.refreshCampaignSortieComparisonPanel();
this.showCampNotice('이후 편성이 변경되어 직전 추천안 적용을 되돌릴 수 없습니다.');
return;
}
this.sortieRecommendationUndoState = undefined;
this.selectedSortieUnitIds = [...undo.before.selectedUnitIds];
this.sortieFormationAssignments = { ...undo.before.formationAssignments };
this.sortieItemAssignments = this.cloneSortieItemAssignments(undo.before.itemAssignments);
this.sortieFocusedUnitId = undo.before.focusedUnitId;
this.sortiePlanFeedback = undo.before.planFeedback;
this.sortieRosterScroll = undo.before.rosterScroll;
this.sortiePortraitRosterPage = undo.before.portraitRosterPage;
this.persistSortieSelection();
const scenario = this.nextSortieScenario();
if (scenario) {
this.campaign = setCampaignSortieResonanceSelection(scenario.id, undo.beforeCoreBondId);
}
this.closeSortieRecommendationBrowserState();
soundDirector.playSelect();
this.showSortiePrep();
this.showCampNotice(`${this.sortieRecommendationDefinition(undo.recommendationId).label} 적용을 되돌렸습니다.`);
}
private confirmPinnedSortiePreset(presetId: CampaignSortiePresetId) {
const preview = this.sortieFormationComparisonPreview();
if (preview?.source !== 'pinned-preset' || preview.presetId !== presetId) {
@@ -17699,6 +18287,7 @@ export class CampScene extends Phaser.Scene {
this.sortieHoveredUnitId = undefined;
if (clearPinnedSwap) {
this.sortiePinnedSwapCandidateUnitId = undefined;
this.closeSortieRecommendationBrowserState();
this.closeSortiePresetBrowserState();
this.sortieFormationPanelMode = 'roster';
this.sortieCoreResonanceSelectorOpen = false;
@@ -17708,6 +18297,8 @@ export class CampScene extends Phaser.Scene {
this.sortiePursuitPanelView = undefined;
this.sortieCoreResonanceToggleButton = undefined;
this.sortieCoreResonanceToggleLabel = undefined;
this.sortieRecommendationBrowserView = undefined;
this.sortieRecommendationToggleButton = undefined;
this.sortiePresetBrowserView = undefined;
this.sortiePresetToggleButton = undefined;
this.sortieOrderBrowserView = undefined;
@@ -20726,6 +21317,9 @@ export class CampScene extends Phaser.Scene {
this.reportFormationHistoryUndoState = undefined;
this.sortieSwapUndoState = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
this.sortieRecommendationUndoState = undefined;
this.sortieHoveredRecommendationId = undefined;
this.sortiePinnedRecommendationId = undefined;
this.sortiePresetUndoState = undefined;
this.sortieHoveredPresetId = undefined;
this.sortiePinnedPresetId = undefined;
@@ -21085,6 +21679,8 @@ export class CampScene extends Phaser.Scene {
const sortiePursuit = this.sortiePursuitPairs();
const sortieCoreResonanceCandidates = this.sortieCoreResonanceCandidates();
const sortieCoreResonanceSelection = this.campaign?.sortieResonanceSelection;
const sortieRecommendationPlanResults = this.sortieRecommendationPlans();
const sortieRecommendationPlans = this.sortieRecommendationProjections();
const recommendedPresetId = this.recommendedSortiePresetId();
const sortieFormationPresets = this.sortieFormationPresets();
const sortieOrderId = this.currentSortieOrderId();
@@ -21464,6 +22060,39 @@ export class CampScene extends Phaser.Scene {
nextSortieBattleId: sortieScenario?.id ?? null,
nextSortieBattleTitle: sortieScenario?.title ?? null,
sortieRecommendationEnabled: this.canApplyRecommendedSortiePlan(sortieScenario),
sortieRecommendationBrowser: {
open: this.sortieRecommendationBrowserOpen,
hoveredPlanId: this.sortieHoveredRecommendationId ?? null,
pinnedPlanId: this.sortiePinnedRecommendationId ?? null,
toggleButtonBounds: this.sortieObjectBoundsDebug(this.sortieRecommendationToggleButton),
browserBounds: this.sortieObjectBoundsDebug(this.sortieRecommendationBrowserView?.background),
closeButtonBounds: this.sortieObjectBoundsDebug(this.sortieRecommendationBrowserView?.closeButton),
cards: sortieRecommendationDefinitions.map((definition) => {
const recommendationPlan = sortieRecommendationPlanResults.find((plan) => plan.id === definition.id);
const projection = sortieRecommendationPlans.find((plan) => plan.recommendationId === definition.id);
const view = this.sortieRecommendationBrowserView?.cards[definition.id];
return {
id: definition.id,
label: definition.label,
title: recommendationPlan?.title ?? null,
applicable: Boolean(recommendationPlan?.applicable),
blockedReason: recommendationPlan?.blockedReason ?? null,
current: Boolean(projection && this.sortieRecommendationProjectionMatchesCurrent(projection)),
selectedUnitIds: projection ? [...projection.selectedUnitIds] : [],
selectedUnitNames: projection ? projection.selectedUnitIds.map((unitId) => this.unitName(unitId)) : [],
formationAssignments: projection ? { ...projection.formationAssignments } : {},
projectedItemAssignments: projection ? this.cloneSortieItemAssignments(projection.itemAssignments) : {},
score: recommendationPlan?.score ?? 0,
summary: recommendationPlan?.summary ?? null,
highlights: recommendationPlan ? [...recommendationPlan.highlights] : [],
tradeoff: recommendationPlan?.tradeoff ?? null,
metrics: recommendationPlan?.metrics ? { ...recommendationPlan.metrics } : null,
unitReasons: recommendationPlan?.unitReasons ? { ...recommendationPlan.unitReasons } : {},
backgroundBounds: this.sortieObjectBoundsDebug(view?.background),
compareButtonBounds: this.sortieObjectBoundsDebug(view?.compareButton)
};
})
},
selectedUnitId: this.selectedUnitId,
selectedDialogueId: this.selectedDialogueId,
selectedVisitId: this.selectedVisitId,
@@ -21528,7 +22157,23 @@ export class CampScene extends Phaser.Scene {
strategyCoverage: sortieComparisonStrategyCoverage
}
: null,
sortieComparisonAction: sortieComparisonAction ? { ...sortieComparisonAction, enabled: true } : null,
sortieComparisonPanel: this.sortieComparisonPanelView
? {
bounds: this.sortieObjectBoundsDebug(this.sortieComparisonPanelView.background),
title: this.sortieComparisonPanelView.title.text,
summary: this.sortieComparisonPanelView.summary.text,
headline: this.sortieComparisonPanelView.headline.text,
impact: this.sortieComparisonPanelView.impact.text,
detail: this.sortieComparisonPanelView.detail.text
}
: null,
sortieComparisonAction: sortieComparisonAction
? {
...sortieComparisonAction,
enabled: true,
buttonBounds: this.sortieInteractiveObjectBoundsDebug(this.sortieComparisonPanelView?.actionButton)
}
: null,
sortieSwapUndo: this.sortieSwapUndoState
? {
available: sortieComparisonAction?.kind === 'undo-swap',
@@ -21542,6 +22187,13 @@ export class CampScene extends Phaser.Scene {
presetId: this.sortiePresetUndoState.presetId
}
: null,
sortieRecommendationUndo: this.sortieRecommendationUndoState
? {
available: sortieComparisonAction?.kind === 'undo-recommendation',
recommendationId: this.sortieRecommendationUndoState.recommendationId,
beforeCoreBondId: this.sortieRecommendationUndoState.beforeCoreBondId ?? null
}
: null,
sortieFocusedUnit: this.sortieFocusedUnitSummary(),
sortieStrategyCoverage: {
total: sortieStrategyCoverage.total,