feat: make sortie feedback actionable

This commit is contained in:
2026-07-15 06:30:26 +09:00
parent 0cadcb22b9
commit b96029bce3
6 changed files with 1506 additions and 38 deletions

View File

@@ -20,7 +20,7 @@ import {
} from '../data/battleUsables';
import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from '../data/battleRules';
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles';
import { getSortieFlow } from '../data/campaignFlow';
import { getSortieFlow, type SortieFlow } from '../data/campaignFlow';
import { getCampSoundscape, type CampSoundscape } from '../data/campSoundscapes';
import {
campaignPortraitKeysByUnitId,
@@ -65,6 +65,10 @@ import {
sortieRecommendationDisplayUnitIds,
type SortieRecommendationOutcome
} from '../data/sortieRecommendationOutcome';
import {
buildSortieRecommendationImprovement,
type SortieRecommendationImprovement
} from '../data/sortieRecommendationImprovement';
import {
sortieOrderDefinition,
sortieOrderDefinitions,
@@ -441,8 +445,8 @@ type SortieComparisonMetric = {
};
type SortieFormationComparisonPreview = {
source: 'focus' | 'hover-add' | 'hover-swap' | 'pinned-swap' | 'hover-blocked' | 'hover-recommendation' | 'pinned-recommendation' | 'hover-preset' | 'pinned-preset';
mode: SortieUnitSynergyPreview['mode'] | 'swap' | 'recommendation' | 'preset';
source: 'focus' | 'guided-improvement' | 'hover-add' | 'hover-swap' | 'pinned-swap' | 'hover-blocked' | 'hover-recommendation' | 'pinned-recommendation' | 'hover-preset' | 'pinned-preset';
mode: SortieUnitSynergyPreview['mode'] | 'swap' | 'recommendation' | 'preset' | 'improvement';
unitId: string;
recommendationId?: SortieRecommendationPlanId;
presetId?: CampaignSortiePresetId;
@@ -561,6 +565,30 @@ type SortieRecommendationUndoState = {
applied: SortieConfigurationSnapshot;
};
type SortieGuidedImprovementState = {
sourceBattleId: string;
targetBattleId: BattleScenarioId;
orderId?: CampaignSortieOrderId;
coreBondId?: string;
proposal: SortieRecommendationImprovement;
};
type SortieGuidedImprovementProjection = {
selectedUnitIds: string[];
formationAssignments: SortieFormationAssignments;
itemAssignments: CampaignSortieItemAssignments;
};
type SortieGuidedImprovementUndoState = {
sourceBattleId: string;
targetBattleId: BattleScenarioId;
proposal: SortieRecommendationImprovement;
beforeCoreBondId?: string;
beforeRecommendation?: CampaignSortieRecommendationSnapshot;
before: SortieConfigurationSnapshot;
applied: SortieConfigurationSnapshot;
};
type SortiePresetUndoState = {
presetId: CampaignSortiePresetId;
before: SortieConfigurationSnapshot;
@@ -627,6 +655,7 @@ type SortieOrderBrowserView = {
type ReportFormationReviewView = {
closeButton: Phaser.GameObjects.Rectangle;
improvementButton?: Phaser.GameObjects.Rectangle;
presetButtons: Partial<Record<CampaignSortiePresetId, Phaser.GameObjects.Rectangle>>;
recommendationPanel?: Phaser.GameObjects.Rectangle;
recommendationRows: {
@@ -696,7 +725,7 @@ type ReportFormationHistoryView = {
};
type SortieComparisonAction = {
kind: 'confirm-swap' | 'undo-swap' | 'confirm-recommendation' | 'undo-recommendation' | 'confirm-preset' | 'undo-preset';
kind: 'confirm-improvement' | 'undo-improvement' | 'confirm-swap' | 'undo-swap' | 'confirm-recommendation' | 'undo-recommendation' | 'confirm-preset' | 'undo-preset';
label: string;
outgoingUnitId?: string;
incomingUnitId?: string;
@@ -715,6 +744,8 @@ type CampaignTimelineChapter = {
type CampSceneData = {
openSortiePrep?: boolean;
openSortieImprovement?: boolean;
retryBattleId?: BattleScenarioId;
skipIntroStory?: boolean;
};
@@ -11222,6 +11253,8 @@ export class CampScene extends Phaser.Scene {
private sortieHoveredRecommendationId?: SortieRecommendationPlanId;
private sortiePinnedRecommendationId?: SortieRecommendationPlanId;
private sortieRecommendationUndoState?: SortieRecommendationUndoState;
private sortieGuidedImprovement?: SortieGuidedImprovementState;
private sortieGuidedImprovementUndoState?: SortieGuidedImprovementUndoState;
private sortieRecommendationPlanCache?: { key: string; plans: SortieRecommendationPlan[] };
private sortieRecommendationBrowserView?: SortieRecommendationBrowserView;
private sortieRecommendationReasonPanelView?: SortieRecommendationReasonPanelView;
@@ -11247,6 +11280,8 @@ export class CampScene extends Phaser.Scene {
private sortiePrepStep: SortiePrepStep = 'briefing';
private terrainCountCache = new Map<BattleScenarioId, SortieTerrainCounts>();
private openSortiePrepOnCreate = false;
private openSortieImprovementOnCreate = false;
private retrySortieBattleId?: BattleScenarioId;
private skipIntroStoryForSortie = false;
constructor() {
@@ -11255,6 +11290,8 @@ export class CampScene extends Phaser.Scene {
init(data?: CampSceneData) {
this.openSortiePrepOnCreate = Boolean(data?.openSortiePrep);
this.openSortieImprovementOnCreate = Boolean(data?.openSortieImprovement);
this.retrySortieBattleId = data?.retryBattleId;
this.skipIntroStoryForSortie = Boolean(data?.skipIntroStory);
}
@@ -11279,6 +11316,8 @@ export class CampScene extends Phaser.Scene {
this.sortieHoveredRecommendationId = undefined;
this.sortiePinnedRecommendationId = undefined;
this.sortieRecommendationUndoState = undefined;
this.sortieGuidedImprovement = undefined;
this.sortieGuidedImprovementUndoState = undefined;
this.sortieRecommendationPlanCache = undefined;
this.sortieRecommendationBrowserView = undefined;
this.sortieRecommendationReasonPanelView = undefined;
@@ -11313,7 +11352,9 @@ export class CampScene extends Phaser.Scene {
this.visitedTabs = new Set(['status']);
this.campaign = getCampaignState();
this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport();
this.ensureCurrentCampRecruitment();
if (!this.retrySortieBattleId) {
this.ensureCurrentCampRecruitment();
}
const sortieFlow = this.currentSortieFlow();
this.campSkinSelection = selectCampSkin({
campaignStep: this.campaign.step,
@@ -11462,7 +11503,12 @@ export class CampScene extends Phaser.Scene {
this.renderStaticButtons();
this.render();
if (this.openSortiePrepOnCreate) {
this.time.delayedCall(0, () => this.showSortiePrep());
this.time.delayedCall(0, () => {
if (this.openSortieImprovementOnCreate) {
this.prepareGuidedSortieImprovement();
}
this.showSortiePrep();
});
}
}
@@ -12999,7 +13045,9 @@ export class CampScene extends Phaser.Scene {
const isFirstSortieSlice = this.isFirstSortiePrepSlice(flow);
const usesStagedPrep = this.usesStagedSortiePrep(flow);
if (usesStagedPrep && !wasVisible) {
this.sortiePrepStep = 'briefing';
this.sortiePrepStep = this.sortieGuidedImprovement || this.sortieGuidedImprovementUndoState
? 'formation'
: 'briefing';
this.sortiePortraitRosterPage = 0;
}
const checklist = this.sortieChecklist();
@@ -14009,7 +14057,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-recommendation' || action.kind === 'undo-preset';
const undo = action.kind === 'undo-improvement' || 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);
@@ -14125,7 +14173,7 @@ export class CampScene extends Phaser.Scene {
const action = this.sortieCoreResonanceSelectorOpen ? undefined : this.sortieComparisonAction(preview);
this.refreshSortieRecommendationReasonPanel(preview);
this.refreshSortieComparisonActionButton(view, action);
const isUndoAction = action?.kind === 'undo-swap' || action?.kind === 'undo-recommendation' || action?.kind === 'undo-preset';
const isUndoAction = action?.kind === 'undo-improvement' || 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);
@@ -14138,7 +14186,7 @@ export class CampScene extends Phaser.Scene {
const lostStrategies = strategyCoverageComparison.lost;
const strategyCoverageDelta = strategyCoverageComparison.delta;
this.setCampaignSortieCoreSelectorVisible(false);
const isHoverPreview = preview?.source === 'hover-add' || preview?.source === 'hover-swap' || preview?.source === 'pinned-swap' ||
const isHoverPreview = preview?.source === 'guided-improvement' || 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);
@@ -14207,12 +14255,16 @@ export class CampScene extends Phaser.Scene {
return;
}
const title = action?.kind === 'undo-recommendation' && this.sortieRecommendationUndoState
const title = action?.kind === 'undo-improvement' && this.sortieGuidedImprovementUndoState
? `보완 완료 · ${this.sortieGuidedImprovementUndoState.proposal.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 === 'guided-improvement'
? `전투 보고 보완 · ${preview.recommendationId ? this.sortieRecommendationDefinition(preview.recommendationId).label : '추천안'}`
: preview.source === 'pinned-recommendation' || preview.source === 'hover-recommendation'
? `${this.sortieRecommendationDefinition(preview.recommendationId ?? 'order').label} 비교`
: preview.source === 'pinned-preset' || preview.source === 'hover-preset'
@@ -14231,7 +14283,7 @@ export class CampScene extends Phaser.Scene {
26
)
);
const headlineColor = isUndoAction || preview.source === 'hover-swap' || preview.source === 'pinned-swap' ||
const headlineColor = isUndoAction || preview.source === 'guided-improvement' || 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'
@@ -14242,7 +14294,9 @@ export class CampScene extends Phaser.Scene {
: preview.mode === 'blocked'
? '#87919c'
: '#d4dce6';
const headline = action?.kind === 'undo-recommendation' && this.sortieRecommendationUndoState
const headline = action?.kind === 'undo-improvement' && this.sortieGuidedImprovementUndoState
? `${this.sortieGuidedImprovementUndoState.proposal.title} · 출진 전 점검`
: action?.kind === 'undo-recommendation' && this.sortieRecommendationUndoState
? `추천 명단 ${current.selectedUnitIds.length}명·역할 적용 · 장비·보급 재점검`
: action?.kind === 'undo-preset' && this.sortiePresetUndoState
? `명단 ${current.selectedUnitIds.length}명·역할 저장값 적용 · 장비·보급 재점검`
@@ -14271,6 +14325,16 @@ export class CampScene extends Phaser.Scene {
.setColor(color);
});
if (action?.kind === 'undo-improvement' && this.sortieGuidedImprovementUndoState) {
view.impact
.setText(this.compactText(this.sortieGuidedImprovementUndoState.proposal.detail, 68))
.setColor('#a8ffd0');
view.detail
.setText('직전 전투의 보완을 적용했습니다. 되돌리기는 한 번만 가능합니다.')
.setColor('#ffdf7b');
return;
}
if (action?.kind === 'undo-recommendation' && this.sortieRecommendationUndoState) {
view.impact
.setText(`현재 전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total} · 역할 ${current.coveredRoleCount}/3 · 추격 ${pursuitChanges.currentPairs.length}조 · 지형 ${current.terrainGrade}`)
@@ -14311,6 +14375,28 @@ export class CampScene extends Phaser.Scene {
return;
}
if (preview.mode === 'improvement' && this.sortieGuidedImprovement) {
const proposal = this.sortieGuidedImprovement.proposal;
view.impact
.setText(this.compactText(proposal.detail, 52))
.setColor('#a8ffd0');
view.detail
.setText(
this.compactText(
proposal.kind === 'swap'
? `한 명만 교체 · OUT ${proposal.outgoingUnitName} / IN ${proposal.incomingUnitName} · 신규 무장 보급 미배정 · 실제 편성 미변경`
: proposal.kind === 'add'
? `빈 자리에 한 명만 추가 · IN ${proposal.incomingUnitName} · 신규 무장 보급 미배정 · 실제 편성 미변경`
: proposal.kind === 'role'
? `한 명의 역할만 변경 · ${proposal.unitName} ${this.sortieFormationRoleLabel(proposal.fromRole, true)}${this.sortieFormationRoleLabel(proposal.toRole, true)} · 실제 편성 미변경`
: `${proposal.instruction} · 확인 전까지 추천 기록 미변경`,
64
)
)
.setColor('#ffdf7b');
return;
}
if (preview.mode === 'recommendation' && preview.recommendationId) {
const recommendation = this.sortieRecommendationProjection(preview.recommendationId);
const pursuitDelta = pursuitChanges.projectedPairs.length - pursuitChanges.currentPairs.length;
@@ -14388,7 +14474,7 @@ export class CampScene extends Phaser.Scene {
return;
}
const undo = action.kind === 'undo-swap' || action.kind === 'undo-recommendation' || action.kind === 'undo-preset';
const undo = action.kind === 'undo-improvement' || action.kind === 'undo-swap' || action.kind === 'undo-recommendation' || action.kind === 'undo-preset';
view.actionButton
.setInteractive({ useHandCursor: true })
.setFillStyle(undo ? 0x243442 : 0x263a2d, 0.98)
@@ -14399,6 +14485,18 @@ export class CampScene extends Phaser.Scene {
private sortieComparisonAction(
preview = this.sortieFormationComparisonPreview()
): SortieComparisonAction | undefined {
if (
preview?.source === 'guided-improvement' &&
preview.mode === 'improvement' &&
this.sortieGuidedImprovement &&
this.sortieGuidedImprovementProjection(this.sortieGuidedImprovement)
) {
return {
kind: 'confirm-improvement',
label: this.sortieGuidedImprovement.proposal.kind === 'tactic' ? '행동 확인' : '보완 적용'
};
}
if (
preview?.source === 'pinned-recommendation' &&
preview.mode === 'recommendation' &&
@@ -14448,6 +14546,14 @@ export class CampScene extends Phaser.Scene {
return undefined;
}
const guidedUndo = this.sortieGuidedImprovementUndoState;
if (guidedUndo && this.sortiePersistedConfigurationMatches(guidedUndo.applied)) {
return {
kind: 'undo-improvement',
label: '되돌리기'
};
}
const recommendationUndo = this.sortieRecommendationUndoState;
if (recommendationUndo && this.sortiePersistedConfigurationMatches(recommendationUndo.applied)) {
return {
@@ -14480,6 +14586,14 @@ export class CampScene extends Phaser.Scene {
private handleSortieComparisonAction() {
const action = this.sortieComparisonAction();
if (action?.kind === 'confirm-improvement') {
this.confirmGuidedSortieImprovement();
return;
}
if (action?.kind === 'undo-improvement') {
this.undoGuidedSortieImprovement();
return;
}
if (action?.kind === 'confirm-recommendation' && action.recommendationId) {
this.confirmPinnedSortieRecommendation(action.recommendationId);
return;
@@ -16051,6 +16165,21 @@ export class CampScene extends Phaser.Scene {
return scenario && selection?.battleId === scenario.id ? selection.bondId : undefined;
}
private validCurrentSortieResonanceBondId(
scenario = this.nextSortieScenario(),
selectedUnitIds: readonly string[] = this.selectedSortieUnitIds
) {
const bondId = this.currentSortieResonanceBondId(scenario);
if (!scenario || !bondId) {
return undefined;
}
const selected = new Set(selectedUnitIds);
const bond = this.sortieSynergyBonds(scenario).find((candidate) => candidate.id === bondId);
return bond && bond.level >= coreSortieResonanceMinLevel && bond.unitIds.every((unitId) => selected.has(unitId))
? bond.id
: undefined;
}
private sortieCoreResonanceCandidates(
snapshot = this.sortieSynergySnapshot(),
scenario = this.nextSortieScenario()
@@ -16107,10 +16236,14 @@ export class CampScene extends Phaser.Scene {
this.persistSortieSelection();
const clearing = this.currentSortieResonanceBondId(scenario) === candidate.id;
this.campaign = setCampaignSortieResonanceSelection(scenario.id, clearing ? undefined : candidate.id);
const guidedRecalculated = this.resetRecommendationAfterSortieStrategyChange();
const pairLabel = `${candidate.unitNames[0]}${candidate.unitNames[1]}`;
this.sortiePlanFeedback = clearing
? `핵심 공명조 해제 · ${pairLabel}`
: `핵심 공명조 지정 · ${pairLabel} · 추격 ${candidate.coreChainRate}%`;
if (guidedRecalculated) {
this.sortiePlanFeedback += ' · 보완안 재계산';
}
this.sortieCoreResonancePage = 0;
soundDirector.playSelect();
this.showCampNotice(this.sortiePlanFeedback);
@@ -16321,6 +16454,150 @@ export class CampScene extends Phaser.Scene {
return this.sortieRecommendationProjections(scenario).find((projection) => projection.recommendationId === recommendationId);
}
private prepareGuidedSortieImprovement() {
const report = this.report;
const review = this.reportFormationReview();
const scenario = this.nextSortieScenario();
const outcome = review ? this.reportSortieRecommendationOutcome(review, report) : undefined;
if (!report || !review || !scenario || !outcome) {
this.sortieGuidedImprovement = undefined;
this.sortieGuidedImprovementUndoState = undefined;
return false;
}
const plans = this.sortieRecommendationPlans(scenario).filter((plan) => plan.applicable && plan.selectedUnitIds.length > 0);
const plan = plans.find((candidate) => candidate.id === outcome.planId) ?? plans[0];
if (!plan) {
this.sortieGuidedImprovement = undefined;
this.sortieGuidedImprovementUndoState = undefined;
return false;
}
const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit]));
const currentFormationAssignments = this.selectedSortieUnitIds.reduce<SortieFormationAssignments>((assignments, unitId) => {
const unit = rosterById.get(unitId);
if (unit) {
assignments[unitId] = this.sortieFormationRole(unit, scenario);
}
return assignments;
}, {});
const unitNames = Object.fromEntries(this.sortieRosterUnits().map((unit) => [unit.id, unit.name]));
const proposal = buildSortieRecommendationImprovement({
plan,
selectedUnitIds: this.selectedSortieUnitIds,
formationAssignments: currentFormationAssignments,
requiredUnitIds: [...this.requiredSortieUnitIdsFor(scenario)],
unitNames,
previousContributions: outcome.unitContributions
});
this.sortieGuidedImprovement = {
sourceBattleId: report.battleId,
targetBattleId: scenario.id,
orderId: this.currentSortieOrderId(),
coreBondId: this.validCurrentSortieResonanceBondId(scenario),
proposal
};
this.sortieGuidedImprovementUndoState = undefined;
this.sortieRecommendationUndoState = undefined;
this.sortiePresetUndoState = undefined;
this.sortieSwapUndoState = undefined;
this.closeSortieFormationPanelBrowser();
this.sortieCoreResonanceSelectorOpen = false;
this.sortiePrepStep = 'formation';
this.sortieFocusedUnitId = proposal.focusUnitId && this.selectedSortieUnitIds.includes(proposal.focusUnitId)
? proposal.focusUnitId
: this.selectedSortieUnitIds[0];
this.sortiePlanFeedback = `${proposal.title} · 적용 전 미리보기`;
return true;
}
private sortieGuidedImprovementProjection(
state = this.sortieGuidedImprovement
): SortieGuidedImprovementProjection | undefined {
const scenario = this.nextSortieScenario();
if (!state || !scenario || scenario.id !== state.targetBattleId) {
return undefined;
}
const proposal = state.proposal;
const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit]));
if (
state.orderId !== this.currentSortieOrderId() ||
state.coreBondId !== this.validCurrentSortieResonanceBondId(scenario) ||
JSON.stringify(proposal.baselineSelectedUnitIds) !== JSON.stringify(this.selectedSortieUnitIds) ||
proposal.baselineSelectedUnitIds.some((unitId) => {
const unit = rosterById.get(unitId);
return !unit || proposal.baselineFormationAssignments[unitId] !== this.sortieFormationRole(unit, scenario);
})
) {
return undefined;
}
const selectedUnitIds = this.normalizedSortieUnitIds([...proposal.projectedSelectedUnitIds]);
const selectedIds = new Set(selectedUnitIds);
const requiredIds = this.requiredSortieUnitIdsFor(scenario);
if (
selectedUnitIds.length !== proposal.projectedSelectedUnitIds.length ||
proposal.projectedSelectedUnitIds.some((unitId) => !selectedIds.has(unitId)) ||
[...requiredIds].some((unitId) => rosterById.has(unitId) && !selectedIds.has(unitId)) ||
selectedUnitIds.some((unitId) => {
const unit = rosterById.get(unitId);
return !unit || !this.sortieUnitAvailability(unit, scenario).available;
})
) {
return undefined;
}
const formationAssignments = selectedUnitIds.reduce<SortieFormationAssignments>((assignments, unitId) => {
const unit = rosterById.get(unitId);
if (unit) {
assignments[unitId] = proposal.projectedFormationAssignments[unitId] ?? this.sortieFormationRole(unit, scenario);
}
return assignments;
}, {});
const itemAssignments = Object.entries(this.cloneSortieItemAssignments(this.sortieItemAssignments))
.reduce<CampaignSortieItemAssignments>((assignments, [unitId, stocks]) => {
if (selectedIds.has(unitId)) {
assignments[unitId] = { ...stocks };
}
return assignments;
}, {});
return { selectedUnitIds, formationAssignments, itemAssignments };
}
private createGuidedSortieRecommendationSnapshot(
state: Pick<SortieGuidedImprovementState, 'targetBattleId' | 'proposal'>
): CampaignSortieRecommendationSnapshot | undefined {
const scenario = this.nextSortieScenario();
if (!scenario || scenario.id !== state.targetBattleId) {
return undefined;
}
const plan = this.sortieRecommendationPlans(scenario).find((candidate) => candidate.id === state.proposal.planId);
if (!plan) {
return undefined;
}
const sortieOrderId = this.currentSortieOrderId();
const sortieResonanceBondId = this.validCurrentSortieResonanceBondId(scenario);
const focusUnitId = state.proposal.focusUnitId;
const unitReasons = Object.fromEntries(this.selectedSortieUnitIds.map((unitId) => [
unitId,
focusUnitId === unitId
? state.proposal.detail
: plan.unitReasons[unitId] ?? `${this.unitName(unitId)}은 보완 후 편성에서 기존 임무를 유지합니다.`
]));
return {
version: 1,
battleId: scenario.id,
planId: state.proposal.planId,
label: `${plan.label} · 보완안`,
summary: state.proposal.title,
...(sortieOrderId ? { sortieOrderId } : {}),
...(sortieResonanceBondId ? { sortieResonanceBondId } : {}),
selectedUnitIds: [...this.selectedSortieUnitIds],
formationAssignments: { ...this.sortieFormationAssignments },
unitReasons
};
}
private sortieRecommendationProjectionMatchesCurrent(projection: SortieRecommendationProjection) {
if (JSON.stringify(projection.selectedUnitIds) !== JSON.stringify(this.selectedSortieUnitIds)) {
return false;
@@ -16487,6 +16764,60 @@ export class CampScene extends Phaser.Scene {
if (this.sortieOrderBrowserOpen) {
return undefined;
}
const guidedImprovement = this.sortieGuidedImprovement;
if (guidedImprovement) {
const projection = this.sortieGuidedImprovementProjection(guidedImprovement);
if (projection) {
const proposal = guidedImprovement.proposal;
const projected = this.sortieSynergySnapshot(
projection.selectedUnitIds,
scenario,
projection.formationAssignments
);
return {
source: 'guided-improvement',
mode: 'improvement',
unitId: proposal.focusUnitId ?? proposal.planId,
recommendationId: proposal.planId,
incomingUnitId: proposal.kind === 'swap' || proposal.kind === 'add' ? proposal.incomingUnitId : null,
incomingUnitName: proposal.kind === 'swap' || proposal.kind === 'add' ? proposal.incomingUnitName : null,
outgoingUnitId: proposal.kind === 'swap' ? proposal.outgoingUnitId : null,
outgoingUnitName: proposal.kind === 'swap' ? proposal.outgoingUnitName : null,
selectedUnitIds,
projectedSelectedUnitIds: projection.selectedUnitIds,
headline: proposal.title,
detail: proposal.kind === 'tactic' ? proposal.instruction : proposal.detail,
metrics: this.sortiePresetComparisonMetrics(projection, scenario),
current,
projected,
comparison: compareSortieSynergy(current, projected)
};
}
}
const guidedUndo = this.sortieGuidedImprovementUndoState;
if (guidedUndo && this.sortiePersistedConfigurationMatches(guidedUndo.applied)) {
const proposal = guidedUndo.proposal;
return {
source: 'guided-improvement',
mode: 'improvement',
unitId: proposal.focusUnitId ?? proposal.planId,
recommendationId: proposal.planId,
incomingUnitId: proposal.kind === 'swap' || proposal.kind === 'add' ? proposal.incomingUnitId : null,
incomingUnitName: proposal.kind === 'swap' || proposal.kind === 'add' ? proposal.incomingUnitName : null,
outgoingUnitId: proposal.kind === 'swap' ? proposal.outgoingUnitId : null,
outgoingUnitName: proposal.kind === 'swap' ? proposal.outgoingUnitName : null,
selectedUnitIds,
projectedSelectedUnitIds: selectedUnitIds,
headline: `${proposal.title} 적용 완료`,
detail: proposal.kind === 'tactic' ? proposal.instruction : proposal.detail,
metrics: this.sortiePresetComparisonMetrics({
selectedUnitIds,
formationAssignments: this.sortieFormationAssignments
}, scenario),
current,
projected: current
};
}
const recommendationId = this.sortieHoveredRecommendationId ?? this.sortiePinnedRecommendationId;
if (recommendationId) {
const projection = this.sortieRecommendationProjection(recommendationId);
@@ -16805,12 +17136,29 @@ export class CampScene extends Phaser.Scene {
return;
}
const definition = sortieOrderDefinition(orderId);
const changed = this.currentSortieOrderId() !== orderId;
this.campaign = setCampaignSortieOrderSelection(scenario.id, orderId);
const guidedRecalculated = changed
? this.resetRecommendationAfterSortieStrategyChange()
: false;
this.sortiePlanFeedback = `${definition.label} 군령 선택 · ${definition.condition}`;
if (guidedRecalculated) {
this.sortiePlanFeedback += ' · 보완안 재계산';
}
soundDirector.playSelect();
this.showSortiePrep();
}
private resetRecommendationAfterSortieStrategyChange() {
const hadGuidedImprovement = Boolean(
this.sortieGuidedImprovement || this.sortieGuidedImprovementUndoState
);
this.sortieGuidedImprovement = undefined;
this.sortieGuidedImprovementUndoState = undefined;
this.persistSortieSelection(null);
return hadGuidedImprovement ? this.prepareGuidedSortieImprovement() : false;
}
private toggleSortiePresetBrowser(forceOpen?: boolean) {
const open = forceOpen ?? !this.sortiePresetBrowserOpen;
if (!open) {
@@ -16973,6 +17321,86 @@ export class CampScene extends Phaser.Scene {
this.showCampNotice(`${definition.label} 편성책 저장 완료 · 보급은 포함하지 않았습니다.`);
}
private confirmGuidedSortieImprovement() {
const state = this.sortieGuidedImprovement;
const projection = this.sortieGuidedImprovementProjection(state);
if (!state || !projection) {
this.sortieGuidedImprovement = undefined;
this.refreshCampaignSortieComparisonPanel();
this.showCampNotice('편성 조건이 달라졌습니다. 전투 보고에서 보완안을 다시 여십시오.');
return;
}
const beforeCoreBondId = this.currentSortieResonanceBondId();
const beforeRecommendation = this.currentSortieRecommendationSelection();
const before = this.captureSortieConfiguration();
const proposal = state.proposal;
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 = proposal.focusUnitId && projection.selectedUnitIds.includes(proposal.focusUnitId)
? proposal.focusUnitId
: projection.selectedUnitIds[0];
this.sortieRosterScroll = 0;
this.sortiePortraitRosterPage = 0;
this.sortiePlanFeedback = proposal.kind === 'tactic'
? `전술 행동 확인 · ${proposal.instruction}`
: `${proposal.title} 적용 완료 · 장비·보급 재점검`;
this.sortieGuidedImprovement = undefined;
this.sortieGuidedImprovementUndoState = {
sourceBattleId: state.sourceBattleId,
targetBattleId: state.targetBattleId,
proposal,
beforeCoreBondId,
beforeRecommendation,
before,
applied: this.captureSortieConfiguration()
};
this.persistSortieSelection(this.createGuidedSortieRecommendationSnapshot(state));
this.closeSortieFormationPanelBrowser();
soundDirector.playSelect();
this.showSortiePrep();
this.showCampNotice(
proposal.kind === 'tactic'
? `${proposal.instruction} 출진 전 행동 지침으로 기록했습니다.`
: `${proposal.title} 적용 완료 · 바뀐 무장의 장비와 보급을 확인하십시오.`
);
}
private undoGuidedSortieImprovement() {
const undo = this.sortieGuidedImprovementUndoState;
if (!undo || !this.sortiePersistedConfigurationMatches(undo.applied)) {
this.sortieGuidedImprovementUndoState = undefined;
this.refreshCampaignSortieComparisonPanel();
this.showCampNotice('이후 편성이 변경되어 직전 보완을 되돌릴 수 없습니다.');
return;
}
this.sortieGuidedImprovementUndoState = 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(null);
const scenario = this.nextSortieScenario();
if (scenario) {
this.campaign = setCampaignSortieResonanceSelection(scenario.id, undo.beforeCoreBondId);
}
if (undo.beforeRecommendation) {
this.persistSortieSelection(undo.beforeRecommendation);
}
this.closeSortieFormationPanelBrowser();
soundDirector.playSelect();
this.showSortiePrep();
this.showCampNotice(`${undo.proposal.title} 적용을 되돌렸습니다.`);
}
private confirmPinnedSortieRecommendation(recommendationId: SortieRecommendationPlanId) {
const preview = this.sortieFormationComparisonPreview();
if (preview?.source !== 'pinned-recommendation' || preview.recommendationId !== recommendationId) {
@@ -17243,7 +17671,7 @@ export class CampScene extends Phaser.Scene {
return undefined;
}
const sortieOrderId = this.currentSortieOrderId();
const sortieResonanceBondId = this.currentSortieResonanceBondId(scenario);
const sortieResonanceBondId = this.validCurrentSortieResonanceBondId(scenario, projection.selectedUnitIds);
return {
version: 1,
battleId: scenario.id,
@@ -17268,7 +17696,7 @@ export class CampScene extends Phaser.Scene {
selection.formationAssignments[unitId] === this.sortieFormationAssignments[unitId]
)) &&
selection.sortieOrderId === this.currentSortieOrderId() &&
selection.sortieResonanceBondId === this.currentSortieResonanceBondId(scenario)
selection.sortieResonanceBondId === this.validCurrentSortieResonanceBondId(scenario)
);
}
@@ -18321,7 +18749,20 @@ export class CampScene extends Phaser.Scene {
};
}
private currentSortieFlow() {
private currentSortieFlow(): SortieFlow {
if (this.retrySortieBattleId) {
const scenario = getBattleScenario(this.retrySortieBattleId);
return {
afterBattleId: this.retrySortieBattleId,
eyebrow: '재도전 준비',
title: scenario.title,
description: '직전 전투의 추천 검증을 바탕으로 한 자리만 보완한 뒤 같은 전장에 다시 출진합니다.',
rewardHint: `재도전 목표: ${scenario.victoryConditionLabel}`,
nextBattleId: this.retrySortieBattleId,
campaignStep: this.campaign?.step,
pages: []
};
}
return getSortieFlow(this.campaign?.latestBattleId, this.campaign?.step);
}
@@ -18531,7 +18972,7 @@ export class CampScene extends Phaser.Scene {
return;
}
if (!flow.nextBattleId || !flow.campaignStep || flow.pages.length === 0) {
if (!flow.nextBattleId || !flow.campaignStep || (!this.skipIntroStoryForSortie && flow.pages.length === 0)) {
this.hideSortiePrep();
if (!flow.nextBattleId && flow.campaignStep && flow.pages.length > 0) {
if (this.campaign?.step === flow.campaignStep) {
@@ -19360,6 +19801,36 @@ export class CampScene extends Phaser.Scene {
this.renderReportFormationReview();
}
private reportSortieImprovementTargetBattleId(report = this.report): BattleScenarioId | undefined {
if (!report) {
return undefined;
}
if (report.outcome === 'defeat') {
return report.battleId as BattleScenarioId;
}
return this.currentSortieFlow().nextBattleId;
}
private openReportGuidedSortieImprovement() {
const report = this.report;
const targetBattleId = this.reportSortieImprovementTargetBattleId(report);
if (!report || !targetBattleId) {
this.showCampNotice('보완안을 적용할 다음 전장이 없습니다.');
return;
}
this.retrySortieBattleId = report.outcome === 'defeat' ? targetBattleId : undefined;
this.skipIntroStoryForSortie = report.outcome === 'defeat';
this.sortieRecommendationPlanCache = undefined;
this.hideReportFormationReview();
if (!this.prepareGuidedSortieImprovement()) {
this.showCampNotice('현재 편성 조건으로 보완안을 계산할 수 없습니다. 출전 전략 추천을 확인하십시오.');
this.showSortiePrep();
return;
}
soundDirector.playSelect();
this.showSortiePrep();
}
private renderReportFormationReview() {
const review = this.reportFormationReview();
const report = this.report;
@@ -19373,6 +19844,9 @@ export class CampScene extends Phaser.Scene {
this.reportFormationReviewView = undefined;
const recommendationOutcome = this.reportSortieRecommendationOutcome(review, report);
const recommendationSnapshot = this.reportSortieRecommendationSnapshot(report);
const improvementTargetBattleId = recommendationOutcome
? this.reportSortieImprovementTargetBattleId(report)
: undefined;
const depth = 52;
const width = 980;
const height = 420;
@@ -19445,8 +19919,8 @@ export class CampScene extends Phaser.Scene {
: '전술 판단',
recommendationOutcome
? [
`${recommendationOutcome.statusLabel} ${recommendationOutcome.score}점 · ${recommendationOutcome.kpis.map((kpi) => `${kpi.label} ${kpi.status === 'hit' ? '적중' : kpi.status === 'partial' ? '부분' : '미달'}`).join(' · ')}`,
`다음 조정 · ${this.compactText(recommendationOutcome.improvement, 58)}`
this.compactText(`${recommendationOutcome.statusLabel} ${recommendationOutcome.score}점 · ${recommendationOutcome.kpis.map((kpi) => `${kpi.label} ${kpi.status === 'hit' ? '적중' : kpi.status === 'partial' ? '부분' : '미달'}`).join(' · ')}`, improvementTargetBattleId ? 40 : 72),
`다음 조정 · ${this.compactText(recommendationOutcome.improvement, improvementTargetBattleId ? 32 : 58)}`
]
: [
`강점 · ${review.strengths.join(' · ')}`,
@@ -19459,6 +19933,20 @@ export class CampScene extends Phaser.Scene {
review.gradeColor,
depth + 2
);
const improvementButton = recommendationOutcome && improvementTargetBattleId
? this.addReportFormationReviewButton(
report.outcome === 'defeat' ? '재도전 보완' : '다음 출전 보완',
x + 456,
analysisY + 18,
146,
36,
true,
false,
() => this.openReportGuidedSortieImprovement(),
depth + 3,
palette.gold
)
: undefined;
this.renderReportFormationAnalysisPanel(
'역할·공명 기여',
[
@@ -19577,6 +20065,7 @@ export class CampScene extends Phaser.Scene {
.setDepth(depth + 2);
this.reportFormationReviewView = {
closeButton,
improvementButton,
presetButtons,
recommendationPanel: recommendationOutcome ? recommendationPanel : undefined,
recommendationRows
@@ -21529,13 +22018,17 @@ export class CampScene extends Phaser.Scene {
private sortieAllies() {
const scenario = this.nextSortieScenario();
const rule = this.nextSortieRule(scenario);
return this.currentUnits().filter((unit) => this.sortieUnitAvailability(unit, scenario, rule).available);
return this.sortieRosterUnits().filter((unit) => this.sortieUnitAvailability(unit, scenario, rule).available);
}
private sortieRosterUnits() {
const campaignOrder = new Map(this.currentUnits().map((unit, index) => [unit.id, index]));
const restoresFreshRetryReadiness = Boolean(this.retrySortieBattleId && !this.campaign?.roster.length);
return this.currentUnits()
.filter((unit) => unit.faction === 'ally')
.map((unit) => restoresFreshRetryReadiness && unit.hp <= 0
? { ...unit, hp: unit.maxHp }
: unit)
.sort((a, b) => (campaignOrder.get(a.id) ?? 0) - (campaignOrder.get(b.id) ?? 0));
}
@@ -21731,7 +22224,7 @@ export class CampScene extends Phaser.Scene {
return true;
}
private persistSortieSelection(recommendationSelection?: CampaignSortieRecommendationSnapshot) {
private persistSortieSelection(recommendationSelection?: CampaignSortieRecommendationSnapshot | null) {
this.reportFormationHistoryUndoState = undefined;
this.sortieSwapUndoState = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
@@ -21748,7 +22241,13 @@ export class CampScene extends Phaser.Scene {
campaign.selectedSortieUnitIds = [...this.selectedSortieUnitIds];
campaign.sortieFormationAssignments = { ...this.sortieFormationAssignments };
campaign.sortieItemAssignments = this.cloneSortieItemAssignments(this.sortieItemAssignments);
const activeRecommendation = recommendationSelection ?? campaign.sortieRecommendationSelection;
const guidedRecommendation = this.sortieGuidedImprovementUndoState &&
this.sortiePersistedConfigurationMatches(this.sortieGuidedImprovementUndoState.applied)
? this.createGuidedSortieRecommendationSnapshot(this.sortieGuidedImprovementUndoState)
: undefined;
const activeRecommendation = recommendationSelection === null
? undefined
: recommendationSelection ?? guidedRecommendation ?? campaign.sortieRecommendationSelection;
if (this.sortieRecommendationSelectionMatchesCurrent(activeRecommendation)) {
campaign.sortieRecommendationSelection = {
...activeRecommendation!,
@@ -22119,6 +22618,9 @@ export class CampScene extends Phaser.Scene {
const reportRecommendationOutcome = reportFormationReview
? this.reportSortieRecommendationOutcome(reportFormationReview)
: undefined;
const reportImprovementTargetBattleId = reportRecommendationOutcome
? this.reportSortieImprovementTargetBattleId()
: undefined;
const reportRecommendationSnapshot = this.reportSortieRecommendationSnapshot();
const reportFormationMatchingPresetIds = reportFormationReview
? this.reportFormationSourcePresetIds(reportFormationReview.snapshot)
@@ -22308,6 +22810,22 @@ export class CampScene extends Phaser.Scene {
})
}
: null,
improvementAction: reportRecommendationOutcome
? {
available: Boolean(reportImprovementTargetBattleId),
label: reportImprovementTargetBattleId
? this.report?.outcome === 'defeat' ? '재도전 보완' : '다음 출전 보완'
: null,
sourceBattleId: this.report?.battleId ?? null,
targetBattleId: reportImprovementTargetBattleId ?? null,
retry: this.report?.outcome === 'defeat',
planId: reportRecommendationOutcome.planId,
weakUnitIds: reportRecommendationOutcome.unitContributions
.filter((entry) => entry.status !== 'hit')
.map((entry) => entry.unitId),
buttonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewView?.improvementButton)
}
: null,
toggleButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewToggleButton),
closeButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewView?.closeButton),
presetCards: sortiePresetDefinitions.map((definition) => ({
@@ -22328,6 +22846,7 @@ export class CampScene extends Phaser.Scene {
overwriteConfirmId: null,
feedback: '',
recommendationFeedback: null,
improvementAction: null,
toggleButtonBounds: null,
closeButtonBounds: null,
presetCards: []
@@ -22655,6 +23174,33 @@ export class CampScene extends Phaser.Scene {
};
})
},
sortieGuidedImprovement: this.sortieGuidedImprovement
? {
stage: 'preview',
sourceBattleId: this.sortieGuidedImprovement.sourceBattleId,
targetBattleId: this.sortieGuidedImprovement.targetBattleId,
proposal: {
...this.sortieGuidedImprovement.proposal,
baselineSelectedUnitIds: [...this.sortieGuidedImprovement.proposal.baselineSelectedUnitIds],
baselineFormationAssignments: { ...this.sortieGuidedImprovement.proposal.baselineFormationAssignments },
projectedSelectedUnitIds: [...this.sortieGuidedImprovement.proposal.projectedSelectedUnitIds],
projectedFormationAssignments: { ...this.sortieGuidedImprovement.proposal.projectedFormationAssignments }
}
}
: this.sortieGuidedImprovementUndoState
? {
stage: 'applied',
sourceBattleId: this.sortieGuidedImprovementUndoState.sourceBattleId,
targetBattleId: this.sortieGuidedImprovementUndoState.targetBattleId,
proposal: {
...this.sortieGuidedImprovementUndoState.proposal,
baselineSelectedUnitIds: [...this.sortieGuidedImprovementUndoState.proposal.baselineSelectedUnitIds],
baselineFormationAssignments: { ...this.sortieGuidedImprovementUndoState.proposal.baselineFormationAssignments },
projectedSelectedUnitIds: [...this.sortieGuidedImprovementUndoState.proposal.projectedSelectedUnitIds],
projectedFormationAssignments: { ...this.sortieGuidedImprovementUndoState.proposal.projectedFormationAssignments }
}
}
: null,
sortieComparisonPreview: sortieComparisonPreview
? {
...sortieComparisonPreview,
@@ -22698,6 +23244,24 @@ export class CampScene extends Phaser.Scene {
beforeCoreBondId: this.sortieRecommendationUndoState.beforeCoreBondId ?? null
}
: null,
sortieGuidedImprovementUndo: this.sortieGuidedImprovementUndoState
? {
available: sortieComparisonAction?.kind === 'undo-improvement',
sourceBattleId: this.sortieGuidedImprovementUndoState.sourceBattleId,
targetBattleId: this.sortieGuidedImprovementUndoState.targetBattleId,
kind: this.sortieGuidedImprovementUndoState.proposal.kind,
before: {
selectedUnitIds: [...this.sortieGuidedImprovementUndoState.before.selectedUnitIds],
formationAssignments: { ...this.sortieGuidedImprovementUndoState.before.formationAssignments },
itemAssignments: this.cloneSortieItemAssignments(this.sortieGuidedImprovementUndoState.before.itemAssignments)
},
applied: {
selectedUnitIds: [...this.sortieGuidedImprovementUndoState.applied.selectedUnitIds],
formationAssignments: { ...this.sortieGuidedImprovementUndoState.applied.formationAssignments },
itemAssignments: this.cloneSortieItemAssignments(this.sortieGuidedImprovementUndoState.applied.itemAssignments)
}
}
: null,
sortieFocusedUnit: this.sortieFocusedUnitSummary(),
sortieStrategyCoverage: {
total: sortieStrategyCoverage.total,