feat: add tactical sortie recommendations
This commit is contained in:
@@ -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",
|
||||
|
||||
439
scripts/verify-sortie-recommendations.mjs
Normal file
439
scripts/verify-sortie-recommendations.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
682
src/game/data/sortieRecommendations.ts
Normal file
682
src/game/data/sortieRecommendations.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user