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