feat: connect sortie recommendations to battle results

This commit is contained in:
2026-07-13 09:50:36 +09:00
parent 7b58317f67
commit 0cadcb22b9
9 changed files with 1958 additions and 104 deletions

View File

@@ -59,6 +59,12 @@ import {
sortiePerformanceRoleContributionLine,
type SortiePerformanceReview
} from '../data/sortiePerformanceReview';
import {
evaluateSortieRecommendationOutcome,
sortieRecommendationContributionCompactLabel,
sortieRecommendationDisplayUnitIds,
type SortieRecommendationOutcome
} from '../data/sortieRecommendationOutcome';
import {
sortieOrderDefinition,
sortieOrderDefinitions,
@@ -174,6 +180,7 @@ import {
type CampaignSortieFormationPresets,
type CampaignSortiePerformanceSnapshot,
type CampaignSortiePresetId,
type CampaignSortieRecommendationSnapshot,
type FirstBattleReport
} from '../state/campaignState';
import { palette } from '../ui/palette';
@@ -621,6 +628,13 @@ type SortieOrderBrowserView = {
type ReportFormationReviewView = {
closeButton: Phaser.GameObjects.Rectangle;
presetButtons: Partial<Record<CampaignSortiePresetId, Phaser.GameObjects.Rectangle>>;
recommendationPanel?: Phaser.GameObjects.Rectangle;
recommendationRows: {
unitId: string;
background: Phaser.GameObjects.Rectangle;
nameText: Phaser.GameObjects.Text;
statusText: Phaser.GameObjects.Text;
}[];
};
type ReportFormationHistoryRecord = {
@@ -16994,7 +17008,7 @@ export class CampScene extends Phaser.Scene {
this.sortieRosterScroll = 0;
this.sortiePortraitRosterPage = 0;
this.sortiePlanFeedback = `${projection.label} 적용 완료 · 장비·보급 재점검`;
this.persistSortieSelection();
this.persistSortieSelection(this.createSortieRecommendationSnapshot(projection));
this.sortieRecommendationUndoState = {
recommendationId,
beforeCoreBondId,
@@ -17221,6 +17235,55 @@ export class CampScene extends Phaser.Scene {
return runtime === expected && saved === expected;
}
private createSortieRecommendationSnapshot(
projection: SortieRecommendationProjection
): CampaignSortieRecommendationSnapshot | undefined {
const scenario = this.nextSortieScenario();
if (!scenario) {
return undefined;
}
const sortieOrderId = this.currentSortieOrderId();
const sortieResonanceBondId = this.currentSortieResonanceBondId(scenario);
return {
version: 1,
battleId: scenario.id,
planId: projection.recommendationId,
label: projection.label,
summary: projection.plan.summary,
...(sortieOrderId ? { sortieOrderId } : {}),
...(sortieResonanceBondId ? { sortieResonanceBondId } : {}),
selectedUnitIds: [...projection.selectedUnitIds],
formationAssignments: { ...projection.formationAssignments },
unitReasons: { ...projection.plan.unitReasons }
};
}
private sortieRecommendationSelectionMatchesCurrent(selection?: CampaignSortieRecommendationSnapshot) {
const scenario = this.nextSortieScenario();
return Boolean(
selection &&
scenario?.id === selection.battleId &&
JSON.stringify(selection.selectedUnitIds) === JSON.stringify(this.selectedSortieUnitIds) &&
selection.selectedUnitIds.every((unitId) => (
selection.formationAssignments[unitId] === this.sortieFormationAssignments[unitId]
)) &&
selection.sortieOrderId === this.currentSortieOrderId() &&
selection.sortieResonanceBondId === this.currentSortieResonanceBondId(scenario)
);
}
private currentSortieRecommendationSelection() {
const selection = this.campaign?.sortieRecommendationSelection;
return this.sortieRecommendationSelectionMatchesCurrent(selection)
? {
...selection!,
selectedUnitIds: [...selection!.selectedUnitIds],
formationAssignments: { ...selection!.formationAssignments },
unitReasons: { ...selection!.unitReasons }
}
: undefined;
}
private confirmPinnedSortieSwap() {
const preview = this.sortieFormationComparisonPreview();
if (
@@ -18510,6 +18573,7 @@ export class CampScene extends Phaser.Scene {
const sortieResonanceBondId = this.campaign?.sortieResonanceSelection?.battleId === flow.nextBattleId
? this.campaign.sortieResonanceSelection.bondId
: undefined;
const sortieRecommendation = this.currentSortieRecommendationSelection();
if (this.skipIntroStoryForSortie) {
void startLazyScene(this, 'BattleScene', {
battleId: flow.nextBattleId,
@@ -18517,7 +18581,8 @@ export class CampScene extends Phaser.Scene {
sortieFormationAssignments,
sortieItemAssignments,
sortieOrderId,
sortieResonanceBondId
sortieResonanceBondId,
sortieRecommendation
});
return;
}
@@ -18531,7 +18596,8 @@ export class CampScene extends Phaser.Scene {
sortieFormationAssignments,
sortieItemAssignments,
sortieOrderId,
sortieResonanceBondId
sortieResonanceBondId,
sortieRecommendation
}
});
}
@@ -18838,7 +18904,7 @@ export class CampScene extends Phaser.Scene {
)
);
const sortieOrderResult = this.campaign?.battleHistory[report.battleId]?.sortieOrder ?? report.sortieOrder;
const sortieOrderResult = report.sortieOrder ?? this.campaign?.battleHistory[report.battleId]?.sortieOrder;
if (sortieOrderResult?.command) {
const commandRecord = this.sortieOrderCommandRecordLine(sortieOrderResult.command);
this.track(
@@ -18866,11 +18932,12 @@ export class CampScene extends Phaser.Scene {
}
const review = this.reportFormationReview();
const recommendationOutcome = review ? this.reportSortieRecommendationOutcome(review) : undefined;
const historyRecords = this.reportFormationHistoryRecords();
const actions: { label: string; action: () => void; assign: (button: Phaser.GameObjects.Rectangle) => void }[] = [];
if (review) {
actions.push({
label: `최근 평가 ${review.grade}`,
label: recommendationOutcome ? `추천 검증 ${recommendationOutcome.statusLabel}` : `최근 평가 ${review.grade}`,
action: () => this.showReportFormationReview(),
assign: (button) => { this.reportFormationReviewToggleButton = button; }
});
@@ -18902,27 +18969,28 @@ export class CampScene extends Phaser.Scene {
}
const settlement = this.campaign?.battleHistory[report.battleId];
const snapshot = settlement?.sortiePerformance ?? report.sortiePerformance;
const snapshot = report.sortiePerformance ?? settlement?.sortiePerformance;
if (!snapshot) {
return undefined;
}
const endUnits = settlement
? settlement.units.map((unit) => ({ id: unit.unitId, hp: unit.hp, maxHp: unit.maxHp }))
: report.units
.filter((unit) => unit.faction === 'ally')
.map((unit) => ({ id: unit.id, hp: unit.hp, maxHp: unit.maxHp }));
const reportEndUnits = report.units
.filter((unit) => unit.faction === 'ally')
.map((unit) => ({ id: unit.id, hp: unit.hp, maxHp: unit.maxHp }));
const endUnits = reportEndUnits.length > 0
? reportEndUnits
: settlement?.units.map((unit) => ({ id: unit.unitId, hp: unit.hp, maxHp: unit.maxHp })) ?? [];
const endUnitIds = new Set(endUnits.map((unit) => unit.id));
if (!snapshot.selectedUnitIds.some((unitId) => endUnitIds.has(unitId))) {
return undefined;
}
const selectedIds = new Set(snapshot.selectedUnitIds);
const storedReview = settlement?.sortieReview ?? report.sortieReview;
const storedReview = report.sortieReview ?? settlement?.sortieReview;
const activeBondCount = storedReview?.activeBondCount ?? report.bonds.filter((bond) => bond.unitIds.every((unitId) => selectedIds.has(unitId))).length;
const review = evaluateSortiePerformanceReview({
outcome: settlement?.outcome ?? report.outcome,
objectives: settlement?.objectives ?? report.objectives,
outcome: report.outcome,
objectives: report.objectives,
enemyCount: storedReview?.enemyCount ?? report.totalEnemies,
activeBondCount,
endUnits,
@@ -18943,6 +19011,38 @@ export class CampScene extends Phaser.Scene {
};
}
private reportSortieRecommendationOutcome(
review: SortiePerformanceReview,
report = this.report
): SortieRecommendationOutcome | undefined {
if (!report) {
return undefined;
}
const settlement = this.campaign?.battleHistory[report.battleId];
const recommendation = report.sortieRecommendation ?? settlement?.sortieRecommendation;
if (!recommendation) {
return undefined;
}
return evaluateSortieRecommendationOutcome({
planId: recommendation.planId,
selectedUnitIds: recommendation.selectedUnitIds,
formationAssignments: recommendation.formationAssignments,
unitReasons: recommendation.unitReasons,
performance: review.snapshot,
outcome: report.outcome,
objectives: report.objectives,
activeBondCount: review.activeBondCount,
sortieOrder: report.sortieOrder ?? settlement?.sortieOrder
});
}
private reportSortieRecommendationSnapshot(report = this.report) {
if (!report) {
return undefined;
}
return report.sortieRecommendation ?? this.campaign?.battleHistory[report.battleId]?.sortieRecommendation;
}
private reportFormationSourcePresetIds(snapshot: CampaignSortiePerformanceSnapshot) {
const presets = this.sortieFormationPresets();
return campaignSortiePresetIds.filter((presetId) => sortiePerformanceMatchesPreset(snapshot, presets[presetId]));
@@ -19271,6 +19371,8 @@ export class CampScene extends Phaser.Scene {
this.reportFormationReviewObjects.forEach((object) => object.destroy());
this.reportFormationReviewObjects = [];
this.reportFormationReviewView = undefined;
const recommendationOutcome = this.reportSortieRecommendationOutcome(review, report);
const recommendationSnapshot = this.reportSortieRecommendationSnapshot(report);
const depth = 52;
const width = 980;
const height = 420;
@@ -19337,12 +19439,19 @@ export class CampScene extends Phaser.Scene {
});
const analysisY = y + 184;
this.renderReportFormationAnalysisPanel(
'전술 판단',
[
`강점 · ${review.strengths.join(' · ')}`,
`보완 · ${this.compactText(review.improvement, 62)}`
],
const recommendationPanel = this.renderReportFormationAnalysisPanel(
recommendationOutcome && recommendationSnapshot
? `추천안 검증 · ${recommendationSnapshot.label}`
: '전술 판단',
recommendationOutcome
? [
`${recommendationOutcome.statusLabel} ${recommendationOutcome.score}점 · ${recommendationOutcome.kpis.map((kpi) => `${kpi.label} ${kpi.status === 'hit' ? '적중' : kpi.status === 'partial' ? '부분' : '미달'}`).join(' · ')}`,
`다음 조정 · ${this.compactText(recommendationOutcome.improvement, 58)}`
]
: [
`강점 · ${review.strengths.join(' · ')}`,
`보완 · ${this.compactText(review.improvement, 62)}`
],
x + 18,
analysisY,
596,
@@ -19354,7 +19463,7 @@ export class CampScene extends Phaser.Scene {
'역할·공명 기여',
[
`${sortiePerformanceRoleContributionLine(review)} · 역할 ${review.roleCoverage}/3`,
`공명 ${review.activeBondCount}개 · 연장 ${review.contributionTotals.extendedBondTriggers}회 · 파훼 ${review.contributionTotals.counterplays}`
`공명 ${review.activeBondCount}개 · 추격 ${review.contributionTotals.extendedBondTriggers}회 · 파훼 ${review.contributionTotals.counterplays}`
],
x + 624,
analysisY,
@@ -19365,35 +19474,61 @@ export class CampScene extends Phaser.Scene {
);
const rosterY = y + 264;
const hiddenUnitCount = Math.max(0, review.snapshot.selectedUnitIds.length - 8);
const contributionByUnitId = new Map(recommendationOutcome?.unitContributions.map((entry) => [entry.unitId, entry]) ?? []);
const actualUnitIds = review.snapshot.selectedUnitIds;
const displayUnitIds = sortieRecommendationDisplayUnitIds(actualUnitIds, recommendationOutcome?.unitContributions ?? []);
const hiddenUnitCount = Math.max(0, displayUnitIds.length - 8);
this.trackReportFormationReview(
this.add.text(
x + 18,
rosterY,
`출전 명단·역할${hiddenUnitCount > 0 ? ` · 외 ${hiddenUnitCount}` : ''}`,
`${recommendationOutcome ? '추천 무장별 판정' : '출전 명단·역할'}${hiddenUnitCount > 0 ? ` · 외 ${hiddenUnitCount}` : ''}`,
this.textStyle(13, '#f2e3bf', true)
)
).setDepth(depth + 2);
const visibleUnitIds = review.snapshot.selectedUnitIds.slice(0, 8);
const visibleUnitIds = displayUnitIds.slice(0, 8);
const recommendationRows: ReportFormationReviewView['recommendationRows'] = [];
const chipGap = 6;
const chipWidth = Math.floor((width - 36 - chipGap * Math.max(0, visibleUnitIds.length - 1)) / Math.max(1, visibleUnitIds.length));
const settlement = this.campaign?.battleHistory[report.battleId];
visibleUnitIds.forEach((unitId, index) => {
const settledUnit = settlement?.units.find((unit) => unit.unitId === unitId);
const reportUnit = report.units.find((unit) => unit.id === unitId);
const hp = settledUnit?.hp ?? reportUnit?.hp ?? 0;
const maxHp = settledUnit?.maxHp ?? reportUnit?.maxHp ?? 0;
const role = review.snapshot.formationAssignments[unitId];
const hp = reportUnit?.hp ?? settledUnit?.hp ?? 0;
const maxHp = reportUnit?.maxHp ?? settledUnit?.maxHp ?? 0;
const contribution = contributionByUnitId.get(unitId);
const role = contribution?.role ?? review.snapshot.formationAssignments[unitId];
const roleLabel = role === 'front' ? '전' : role === 'flank' ? '돌' : role === 'support' ? '후' : '예';
const statusColor = contribution
? this.reportSortieRecommendationOutcomeColor(contribution.status)
: recommendationOutcome
? { label: '변경', tone: palette.red, text: '#ffb6a6' }
: undefined;
const chipX = x + 18 + index * (chipWidth + chipGap);
const chip = this.trackReportFormationReview(this.add.rectangle(chipX, rosterY + 23, chipWidth, 38, hp > 0 ? 0x172a22 : 0x2a1b18, 0.94));
chip.setOrigin(0);
chip.setDepth(depth + 2);
chip.setStrokeStyle(1, hp > 0 ? palette.green : palette.red, 0.58);
this.trackReportFormationReview(this.add.text(chipX + 7, rosterY + 29, `${roleLabel} ${this.compactText(reportUnit?.name ?? this.unitName(unitId), 7)}`, this.textStyle(10, '#f2e3bf', true)))
.setDepth(depth + 3);
this.trackReportFormationReview(this.add.text(chipX + 7, rosterY + 45, `HP ${hp}/${maxHp}`, this.textStyle(9, hp > 0 ? '#a8ffd0' : '#ffb6a6', true)))
chip.setStrokeStyle(1, statusColor?.tone ?? (hp > 0 ? palette.green : palette.red), 0.68);
const nameText = this.trackReportFormationReview(this.add.text(chipX + 7, rosterY + 29, `${roleLabel} ${this.compactText(reportUnit?.name ?? this.unitName(unitId), 7)}`, {
...this.textStyle(10, '#f2e3bf', true),
fixedWidth: chipWidth - 14
}))
.setDepth(depth + 3);
const metric = contribution ? this.reportSortieRecommendationContributionCompact(contribution) : recommendationOutcome ? '추천 외' : undefined;
const statusText = this.trackReportFormationReview(this.add.text(
chipX + 7,
rosterY + 45,
recommendationOutcome
? `${statusColor?.label} · ${metric}`
: `HP ${hp}/${maxHp}`,
{
...this.textStyle(8, statusColor?.text ?? (hp > 0 ? '#a8ffd0' : '#ffb6a6'), true),
fixedWidth: chipWidth - 14
}
)).setDepth(depth + 3);
if (recommendationOutcome) {
recommendationRows.push({ unitId, background: chip, nameText, statusText });
}
});
const presetTitleY = y + 337;
@@ -19440,7 +19575,12 @@ export class CampScene extends Phaser.Scene {
this.trackReportFormationReview(this.add.text(x + 18, y + height - 3, this.compactText(feedback, 78), this.textStyle(10, feedbackColor, true)))
.setOrigin(0, 1)
.setDepth(depth + 2);
this.reportFormationReviewView = { closeButton, presetButtons };
this.reportFormationReviewView = {
closeButton,
presetButtons,
recommendationPanel: recommendationOutcome ? recommendationPanel : undefined,
recommendationRows
};
}
private hideReportFormationReview(resetFeedback = true) {
@@ -19504,6 +19644,22 @@ export class CampScene extends Phaser.Scene {
.setDepth(depth + 1);
}
private reportSortieRecommendationOutcomeColor(status: SortieRecommendationOutcome['status']) {
if (status === 'hit') {
return { label: '적중', tone: palette.green, text: '#a8ffd0' };
}
if (status === 'partial') {
return { label: '부분', tone: palette.gold, text: '#ffdf7b' };
}
return { label: '미달', tone: palette.red, text: '#ffb6a6' };
}
private reportSortieRecommendationContributionCompact(
contribution: SortieRecommendationOutcome['unitContributions'][number]
) {
return sortieRecommendationContributionCompactLabel(contribution);
}
private renderReportFormationAnalysisPanel(
title: string,
lines: string[],
@@ -19523,6 +19679,7 @@ export class CampScene extends Phaser.Scene {
this.trackReportFormationReview(this.add.text(x + 12, y + 30 + index * 18, this.compactText(line, width > 500 ? 72 : 42), this.textStyle(10, index === 0 ? '#d4dce6' : '#ffdf7b', true)))
.setDepth(depth + 1);
});
return background;
}
private addReportFormationReviewButton(
@@ -21574,7 +21731,7 @@ export class CampScene extends Phaser.Scene {
return true;
}
private persistSortieSelection() {
private persistSortieSelection(recommendationSelection?: CampaignSortieRecommendationSnapshot) {
this.reportFormationHistoryUndoState = undefined;
this.sortieSwapUndoState = undefined;
this.sortiePinnedSwapCandidateUnitId = undefined;
@@ -21591,6 +21748,17 @@ 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;
if (this.sortieRecommendationSelectionMatchesCurrent(activeRecommendation)) {
campaign.sortieRecommendationSelection = {
...activeRecommendation!,
selectedUnitIds: [...activeRecommendation!.selectedUnitIds],
formationAssignments: { ...activeRecommendation!.formationAssignments },
unitReasons: { ...activeRecommendation!.unitReasons }
};
} else {
delete campaign.sortieRecommendationSelection;
}
this.campaign = saveCampaignState(campaign);
}
@@ -21948,6 +22116,10 @@ export class CampScene extends Phaser.Scene {
const sortieOrderSummaries = this.sortieOrderSummaries();
const sortieOrderBattleResults = sortieScenario ? this.campaign?.sortieOrderHistory[sortieScenario.id] : undefined;
const reportFormationReview = this.reportFormationReview();
const reportRecommendationOutcome = reportFormationReview
? this.reportSortieRecommendationOutcome(reportFormationReview)
: undefined;
const reportRecommendationSnapshot = this.reportSortieRecommendationSnapshot();
const reportFormationMatchingPresetIds = reportFormationReview
? this.reportFormationSourcePresetIds(reportFormationReview.snapshot)
: [];
@@ -22027,6 +22199,7 @@ export class CampScene extends Phaser.Scene {
};
})
},
sortieRecommendationSelection: this.currentSortieRecommendationSelection() ?? null,
reportOperationOrder: reportSortieOrder
? {
available: true,
@@ -22088,6 +22261,53 @@ export class CampScene extends Phaser.Scene {
matchingPresetIds: [...reportFormationMatchingPresetIds],
overwriteConfirmId: this.reportFormationPresetOverwriteConfirmId ?? null,
feedback: this.reportFormationReviewFeedback,
recommendationFeedback: reportRecommendationOutcome && reportRecommendationSnapshot
? {
planId: reportRecommendationOutcome.planId,
label: reportRecommendationSnapshot.label,
status: reportRecommendationOutcome.status,
statusLabel: reportRecommendationOutcome.statusLabel,
score: reportRecommendationOutcome.score,
summary: reportRecommendationOutcome.summary,
improvement: reportRecommendationOutcome.improvement,
kpis: reportRecommendationOutcome.kpis.map((kpi) => ({ ...kpi })),
counts: {
hit: reportRecommendationOutcome.unitContributions.filter((entry) => entry.status === 'hit').length,
partial: reportRecommendationOutcome.unitContributions.filter((entry) => entry.status === 'partial').length,
miss: reportRecommendationOutcome.unitContributions.filter((entry) => entry.status === 'miss').length
},
missingUnitIds: reportRecommendationOutcome.unitContributions.filter((entry) => !entry.deployed).map((entry) => entry.unitId),
unexpectedUnitIds: (reportFormationReview?.snapshot.selectedUnitIds ?? []).filter(
(unitId) => !reportRecommendationOutcome.unitContributions.some((entry) => entry.unitId === unitId)
),
displayedUnitIds: this.reportFormationReviewView?.recommendationRows.map((row) => row.unitId) ?? [],
panelBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewView?.recommendationPanel),
rows: reportRecommendationOutcome.unitContributions.map((entry) => {
const rowView = this.reportFormationReviewView?.recommendationRows.find((row) => row.unitId === entry.unitId);
const settlementUnit = this.report && this.campaign?.battleHistory[this.report.battleId]?.units.find((unit) => unit.unitId === entry.unitId);
const reportUnit = this.report?.units.find((unit) => unit.id === entry.unitId);
return {
unitId: entry.unitId,
name: reportUnit?.name ?? settlementUnit?.name ?? this.unitName(entry.unitId),
role: entry.role,
actualRole: entry.actualRole ?? null,
fullReason: entry.reason,
status: entry.status,
score: entry.score,
headline: entry.headline,
metrics: entry.metrics.map((metric) => ({ ...metric })),
strategyUses: entry.strategyUses,
signatureStrategyUses: entry.signatureStrategyUses,
hp: reportUnit?.hp ?? settlementUnit?.hp ?? 0,
maxHp: reportUnit?.maxHp ?? settlementUnit?.maxHp ?? 0,
cardBounds: this.sortieObjectBoundsDebug(rowView?.background),
nameBounds: this.sortieTextBoundsDebug(rowView?.nameText),
statusBounds: this.sortieTextBoundsDebug(rowView?.statusText),
displayText: rowView?.statusText.text ?? null
};
})
}
: null,
toggleButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewToggleButton),
closeButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewView?.closeButton),
presetCards: sortiePresetDefinitions.map((definition) => ({
@@ -22107,6 +22327,7 @@ export class CampScene extends Phaser.Scene {
matchingPresetIds: [],
overwriteConfirmId: null,
feedback: '',
recommendationFeedback: null,
toggleButtonBounds: null,
closeButtonBounds: null,
presetCards: []