feat: add tactical sortie recommendations

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

View File

@@ -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,