feat: reopen formation reviews in camp

This commit is contained in:
2026-07-10 23:38:14 +09:00
parent 78669d3c6e
commit f3d0127ab7
4 changed files with 1081 additions and 151 deletions

View File

@@ -217,6 +217,135 @@ try {
`Expected campaign report debug state to retain first battle unlock reward: ${JSON.stringify(firstCampProbe.state?.report?.campaignRewards)}` `Expected campaign report debug state to retain first battle unlock reward: ${JSON.stringify(firstCampProbe.state?.report?.campaignRewards)}`
); );
const firstCampEvaluation = firstCampProbe.state?.reportFormationEvaluation;
const firstCampEvaluationSaveBefore = await readCampaignSave(page);
assert(
firstCampEvaluation?.available === true &&
firstCampEvaluation.open === false &&
firstCampEvaluation.battleId === 'first-battle-zhuo-commandery' &&
firstCampEvaluation.grade === firstResultState?.sortieEvaluation?.grade &&
firstCampEvaluation.score === firstResultState?.sortieEvaluation?.score &&
sameJsonValue(firstCampEvaluation.snapshot, firstResultPerformance) &&
firstCampEvaluation.toggleButtonBounds,
`Expected the first camp report to expose the persisted battle formation evaluation: ${JSON.stringify(firstCampEvaluation)}`
);
const firstCampEvaluationTogglePoint = await readCampReportFormationEvaluationControlPoint(page, 'toggle');
await page.mouse.click(firstCampEvaluationTogglePoint.x, firstCampEvaluationTogglePoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation?.open === true,
undefined,
{ timeout: 30000 }
);
const firstCampEvaluationProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
return {
evaluation: window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation,
texts: (scene?.reportFormationReviewObjects ?? [])
.filter((object) => object?.type === 'Text')
.map((object) => object.text)
};
});
const firstCampEvaluationSaveOpen = await readCampaignSave(page);
assert(
firstCampEvaluationProbe.evaluation?.open === true &&
firstCampEvaluationProbe.texts.includes('최근 전투 편성 평가') &&
firstCampEvaluationProbe.texts.some((text) => text.includes('명단·역할만 저장') && text.includes('장비·보급 제외')) &&
firstCampEvaluationProbe.evaluation?.presetCards?.length === 3 &&
firstCampEvaluationProbe.evaluation.presetCards.every(
(card) => card.saved === false && card.matching === false && card.actionButtonBounds
) &&
sameJsonValue(firstCampEvaluationSaveOpen, firstCampEvaluationSaveBefore),
`Expected reopening the first camp formation evaluation to remain non-destructive and show three empty preset actions: ${JSON.stringify(firstCampEvaluationProbe)}`
);
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-formation-evaluation.png`, fullPage: true });
await assertCanvasPainted(page, 'first camp formation evaluation');
const firstCampEvaluationClosePoint = await readCampReportFormationEvaluationControlPoint(page, 'close');
await page.mouse.click(firstCampEvaluationClosePoint.x, firstCampEvaluationClosePoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation?.open === false,
undefined,
{ timeout: 30000 }
);
const firstCampSupplyMutationFixture = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const state = window.__HEROS_DEBUG__?.camp();
const campaign = scene?.campaign;
const report = scene?.report;
const targetId = state?.reportFormationEvaluation?.snapshot?.selectedUnitIds?.find((unitId) =>
campaign?.roster?.some((unit) => unit.id === unitId)
);
const target = campaign?.roster?.find((unit) => unit.id === targetId);
const reportUnit = report?.units?.find((unit) => unit.id === targetId && unit.faction === 'ally');
const settlementUnit = campaign?.battleHistory?.[report?.battleId]?.units?.find((unit) => unit.unitId === targetId);
if (!scene || !campaign || !report || !target || !reportUnit || !settlementUnit || typeof scene.render !== 'function') {
return { ready: false, targetId: targetId ?? null };
}
const beanBefore = campaign.inventory?.['콩'] ?? 0;
target.hp = Math.max(1, target.maxHp - 12);
reportUnit.hp = target.hp;
campaign.inventory['콩'] = beanBefore + 1;
scene.selectedUnitId = target.id;
scene.render();
return {
ready: true,
targetId: target.id,
woundedHp: target.hp,
maxHp: target.maxHp,
settlementHp: settlementUnit.hp,
beanBefore
};
});
assert(
firstCampSupplyMutationFixture.ready === true &&
firstCampSupplyMutationFixture.targetId &&
firstCampSupplyMutationFixture.woundedHp < firstCampSupplyMutationFixture.maxHp,
`Expected a deterministic wounded report-unit fixture for camp supply recovery: ${JSON.stringify(firstCampSupplyMutationFixture)}`
);
await page.mouse.click(798, 38);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.activeTab === 'supplies',
undefined,
{ timeout: 30000 }
);
const firstCampUseSupplyPoint = await readCampContentTextControlPoint(page, '사용', 0);
await page.mouse.click(firstCampUseSupplyPoint.x, firstCampUseSupplyPoint.y);
await page.waitForFunction((fixture) => {
const state = window.__HEROS_DEBUG__?.camp();
const target = state?.campaign?.roster?.find((unit) => unit.id === fixture.targetId);
return target?.hp === fixture.maxHp;
}, firstCampSupplyMutationFixture, { timeout: 30000 });
const firstCampAfterSupplyProbe = await page.evaluate((fixture) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const state = window.__HEROS_DEBUG__?.camp();
const report = scene?.report;
const settlement = scene?.campaign?.battleHistory?.[report?.battleId];
return {
evaluation: state?.reportFormationEvaluation,
rosterHp: state?.campaign?.roster?.find((unit) => unit.id === fixture.targetId)?.hp ?? null,
reportHp: report?.units?.find((unit) => unit.id === fixture.targetId && unit.faction === 'ally')?.hp ?? null,
settlementHp: settlement?.units?.find((unit) => unit.unitId === fixture.targetId)?.hp ?? null,
beanAmount: state?.campaign?.inventory?.['콩'] ?? 0
};
}, firstCampSupplyMutationFixture);
assert(
firstCampAfterSupplyProbe.rosterHp === firstCampSupplyMutationFixture.maxHp &&
firstCampAfterSupplyProbe.reportHp === firstCampSupplyMutationFixture.maxHp &&
firstCampAfterSupplyProbe.settlementHp === firstCampSupplyMutationFixture.settlementHp &&
firstCampAfterSupplyProbe.beanAmount === firstCampSupplyMutationFixture.beanBefore &&
firstCampAfterSupplyProbe.evaluation?.grade === firstCampEvaluation.grade &&
firstCampAfterSupplyProbe.evaluation?.score === firstCampEvaluation.score &&
firstCampAfterSupplyProbe.evaluation?.survivorCount === firstCampEvaluation.survivorCount &&
firstCampAfterSupplyProbe.evaluation?.recoveryNeededCount === firstCampEvaluation.recoveryNeededCount &&
sameJsonValue(firstCampAfterSupplyProbe.evaluation?.snapshot, firstCampEvaluation.snapshot),
`Expected camp supply use to update report HP without rewriting the settlement-backed formation evaluation: ${JSON.stringify({
before: firstCampEvaluation,
fixture: firstCampSupplyMutationFixture,
after: firstCampAfterSupplyProbe
})}`
);
const firstCampSelectedSortieUnitIds = firstCampProbe.state?.selectedSortieUnitIds ?? []; const firstCampSelectedSortieUnitIds = firstCampProbe.state?.selectedSortieUnitIds ?? [];
const firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? []; const firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? [];
await page.mouse.click(1160, 38); await page.mouse.click(1160, 38);
@@ -1008,6 +1137,44 @@ try {
})}` })}`
); );
const openPresetBookForSiegeFixturePoint = await readSortiePresetControlPoint(page, 'toggle');
await page.mouse.click(openPresetBookForSiegeFixturePoint.x, openPresetBookForSiegeFixturePoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.sortiePresetBrowser?.open === true,
undefined,
{ timeout: 30000 }
);
const saveSiegePresetPoint = await readSortiePresetControlPoint(page, 'save', 'siege');
await page.mouse.click(saveSiegePresetPoint.x, saveSiegePresetPoint.y);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.camp();
return state?.sortiePresetBrowser?.open === true &&
state?.sortieFormationPresets?.siege?.selectedUnitIds?.length > 0;
}, undefined, { timeout: 30000 });
const savedSiegeFixtureState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const savedSiegeFixtureSave = await readCampaignSave(page);
const savedSiegePreset = savedSiegeFixtureState?.sortieFormationPresets?.siege;
assert(
savedSiegePreset?.selectedUnitIds?.length === savedSiegeFixtureState?.selectedSortieUnitIds?.length &&
savedSiegePreset.selectedUnitIds.every((unitId) => Boolean(savedSiegePreset.formationAssignments?.[unitId])) &&
JSON.stringify(Object.keys(savedSiegePreset).sort()) === JSON.stringify(['formationAssignments', 'selectedUnitIds']) &&
savedSiegePreset.sortieItemAssignments === undefined &&
savedSiegePreset.itemAssignments === undefined &&
sameJsonValue(savedSiegeFixtureSave.current?.sortieFormationPresets?.siege, savedSiegePreset) &&
sameJsonValue(savedSiegeFixtureSave.slot1?.sortieFormationPresets?.siege, savedSiegePreset),
`Expected a deterministic pre-battle siege preset fixture containing only the current roster and roles: ${JSON.stringify({
state: savedSiegeFixtureState,
save: savedSiegeFixtureSave
})}`
);
const closePresetBookAfterSiegeFixturePoint = await readSortiePresetControlPoint(page, 'close');
await page.mouse.click(closePresetBookAfterSiegeFixturePoint.x, closePresetBookAfterSiegeFixturePoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.sortiePresetBrowser?.open === false,
undefined,
{ timeout: 30000 }
);
const midFormationFirstPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); const midFormationFirstPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView);
await page.mouse.click(658, 196); await page.mouse.click(658, 196);
await page.waitForFunction((previousScroll) => { await page.waitForFunction((previousScroll) => {
@@ -1363,6 +1530,146 @@ try {
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-formation-evaluation-updated.png`, fullPage: true }); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-formation-evaluation-updated.png`, fullPage: true });
await assertCanvasPainted(page, 'mid camp formation evaluation update'); await assertCanvasPainted(page, 'mid camp formation evaluation update');
const closeMidBattleEvaluationPoint = await readBattleResultEvaluationControlPoint(page, 'close');
await page.mouse.click(closeMidBattleEvaluationPoint.x, closeMidBattleEvaluationPoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false,
undefined,
{ timeout: 30000 }
);
await page.mouse.click(738, 642);
await waitForCampAfterBattleResult(page);
const midCampReviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const midCampReviewSaveBefore = await readCampaignSave(page);
const midCampFormationEvaluation = midCampReviewState?.reportFormationEvaluation;
const midCampSiegeCard = midCampFormationEvaluation?.presetCards?.find((card) => card.id === 'siege');
assert(
midCampFormationEvaluation?.available === true &&
midCampFormationEvaluation.open === false &&
midCampFormationEvaluation.battleId === midCampNextBattleId &&
midCampFormationEvaluation.grade === updatedMobileResultState?.sortieEvaluation?.grade &&
midCampFormationEvaluation.score === updatedMobileResultState?.sortieEvaluation?.score &&
sameJsonValue(midCampFormationEvaluation.snapshot, midResultPerformance) &&
midCampFormationEvaluation.sourcePresetIds?.includes('mobile') === true &&
midCampFormationEvaluation.sourcePresetIds?.includes('siege') === false &&
midCampSiegeCard?.saved === true &&
midCampSiegeCard.matching === false &&
midCampFormationEvaluation.toggleButtonBounds &&
!sameJsonValue(savedSiegePreset, expectedMidResultPreset),
`Expected the next camp report to reopen the persisted result while keeping the older siege preset distinct: ${JSON.stringify({
evaluation: midCampFormationEvaluation,
savedSiegePreset
})}`
);
const midCampReviewTogglePoint = await readCampReportFormationEvaluationControlPoint(page, 'toggle');
await page.mouse.click(midCampReviewTogglePoint.x, midCampReviewTogglePoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation?.open === true,
undefined,
{ timeout: 30000 }
);
const openedMidCampReviewProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
return {
state: window.__HEROS_DEBUG__?.camp(),
texts: (scene?.reportFormationReviewObjects ?? [])
.filter((object) => object?.type === 'Text')
.map((object) => object.text)
};
});
const openedMidCampReviewSave = await readCampaignSave(page);
const openedMidCampSiegeCard = openedMidCampReviewProbe.state?.reportFormationEvaluation?.presetCards?.find(
(card) => card.id === 'siege'
);
assert(
openedMidCampReviewProbe.state?.reportFormationEvaluation?.open === true &&
openedMidCampReviewProbe.texts.includes('최근 전투 편성 평가') &&
openedMidCampReviewProbe.texts.some((text) => text.includes('공성') && text.includes('갱신')) &&
openedMidCampSiegeCard?.saved === true &&
openedMidCampSiegeCard.matching === false &&
openedMidCampSiegeCard.actionButtonBounds &&
sameJsonValue(openedMidCampReviewSave, midCampReviewSaveBefore),
`Expected opening the camp report evaluation to remain non-destructive and expose the siege update action: ${JSON.stringify(openedMidCampReviewProbe)}`
);
const firstCampSiegeUpdatePoint = await readCampReportFormationEvaluationControlPoint(page, 'preset', 'siege');
await page.mouse.click(firstCampSiegeUpdatePoint.x, firstCampSiegeUpdatePoint.y);
await page.waitForFunction(() => {
const evaluation = window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation;
return evaluation?.open === true &&
evaluation?.overwriteConfirmId === 'siege' &&
evaluation?.feedback?.includes('한 번 더');
}, undefined, { timeout: 30000 });
const pendingCampSiegeUpdateState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const pendingCampSiegeUpdateSave = await readCampaignSave(page);
assert(
pendingCampSiegeUpdateState?.reportFormationEvaluation?.sourcePresetIds?.includes('siege') === false &&
pendingCampSiegeUpdateState?.reportFormationEvaluation?.presetCards?.find((card) => card.id === 'siege')?.matching === false &&
sameJsonValue(pendingCampSiegeUpdateSave, midCampReviewSaveBefore),
`Expected the first camp siege update click to request confirmation without mutating either save: ${JSON.stringify({
evaluation: pendingCampSiegeUpdateState?.reportFormationEvaluation,
save: pendingCampSiegeUpdateSave
})}`
);
const confirmCampSiegeUpdatePoint = await readCampReportFormationEvaluationControlPoint(page, 'preset', 'siege');
await page.mouse.click(confirmCampSiegeUpdatePoint.x, confirmCampSiegeUpdatePoint.y);
await page.waitForFunction(() => {
const evaluation = window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation;
return evaluation?.open === true &&
evaluation?.overwriteConfirmId === null &&
evaluation?.sourcePresetIds?.includes('siege') &&
evaluation?.feedback?.includes('갱신 완료');
}, undefined, { timeout: 30000 });
const updatedCampSiegeState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const updatedCampSiegeSave = await readCampaignSave(page);
const updatedCurrentSiegePreset = updatedCampSiegeSave.current?.sortieFormationPresets?.siege;
const updatedSlotSiegePreset = updatedCampSiegeSave.slot1?.sortieFormationPresets?.siege;
assert(
sameJsonValue(updatedCurrentSiegePreset, expectedMidResultPreset) &&
sameJsonValue(updatedSlotSiegePreset, expectedMidResultPreset) &&
JSON.stringify(Object.keys(updatedCurrentSiegePreset ?? {}).sort()) === JSON.stringify(['formationAssignments', 'selectedUnitIds']) &&
updatedCurrentSiegePreset?.sortieItemAssignments === undefined &&
updatedCurrentSiegePreset?.itemAssignments === undefined &&
updatedCurrentSiegePreset?.unitStats === undefined,
`Expected the confirmed camp action to store only the reviewed roster and roles in both siege preset saves: ${JSON.stringify(updatedCampSiegeSave)}`
);
assert(
JSON.stringify(updatedCampSiegeSave.current?.selectedSortieUnitIds) === JSON.stringify(midCampReviewSaveBefore.current?.selectedSortieUnitIds) &&
sameJsonValue(updatedCampSiegeSave.current?.sortieFormationAssignments, midCampReviewSaveBefore.current?.sortieFormationAssignments) &&
sameJsonValue(updatedCampSiegeSave.current?.sortieItemAssignments, midCampReviewSaveBefore.current?.sortieItemAssignments) &&
JSON.stringify(updatedCampSiegeSave.slot1?.selectedSortieUnitIds) === JSON.stringify(midCampReviewSaveBefore.slot1?.selectedSortieUnitIds) &&
sameJsonValue(updatedCampSiegeSave.slot1?.sortieFormationAssignments, midCampReviewSaveBefore.slot1?.sortieFormationAssignments) &&
sameJsonValue(updatedCampSiegeSave.slot1?.sortieItemAssignments, midCampReviewSaveBefore.slot1?.sortieItemAssignments) &&
sameJsonValue(updatedCampSiegeSave.current?.firstBattleReport?.sortiePerformance, midResultPerformance) &&
sameJsonValue(updatedCampSiegeSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) &&
sameJsonValue(updatedCampSiegeSave.current?.sortieFormationPresets?.mobile, midCampReviewSaveBefore.current?.sortieFormationPresets?.mobile) &&
sameJsonValue(updatedCampSiegeSave.current?.sortieFormationPresets?.elite, midCampReviewSaveBefore.current?.sortieFormationPresets?.elite) &&
JSON.stringify(updatedCampSiegeState?.selectedSortieUnitIds) === JSON.stringify(midCampReviewState?.selectedSortieUnitIds) &&
sameJsonValue(updatedCampSiegeState?.sortieFormationAssignments, midCampReviewState?.sortieFormationAssignments) &&
sameJsonValue(updatedCampSiegeState?.sortieItemAssignments, midCampReviewState?.sortieItemAssignments),
`Expected the camp review update to preserve the active sortie, supplies, report snapshot, and other presets: ${JSON.stringify({
before: midCampReviewSaveBefore,
after: updatedCampSiegeSave
})}`
);
assert(
updatedCampSiegeState?.reportFormationEvaluation?.presetCards?.find((card) => card.id === 'siege')?.matching === true &&
updatedCampSiegeState?.reportFormationEvaluation?.feedback?.includes('장비·보급은 저장하지 않았습니다.'),
`Expected visible completion feedback and a matching siege card after the camp update: ${JSON.stringify(updatedCampSiegeState?.reportFormationEvaluation)}`
);
await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-report-formation-evaluation-updated.png`, fullPage: true });
await assertCanvasPainted(page, 'mid camp report formation evaluation update');
const closeUpdatedCampReviewPoint = await readCampReportFormationEvaluationControlPoint(page, 'close');
await page.mouse.click(closeUpdatedCampReviewPoint.x, closeUpdatedCampReviewPoint.y);
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation?.open === false,
undefined,
{ timeout: 30000 }
);
await seedCampaignSave(page, { await seedCampaignSave(page, {
battleId: 'sixty-sixth-battle-wuzhang-final', battleId: 'sixty-sixth-battle-wuzhang-final',
battleTitle: '오장원 최종전', battleTitle: '오장원 최종전',
@@ -1381,6 +1688,13 @@ try {
assert(finalCampState.sortieHasBattle === false, `Expected final camp to expose no playable sortie target: ${JSON.stringify(finalCampState)}`); assert(finalCampState.sortieHasBattle === false, `Expected final camp to expose no playable sortie target: ${JSON.stringify(finalCampState)}`);
assert(finalCampState.stagedSortiePrep === false, `Expected the non-battle final camp to avoid the three-stage sortie flow: ${JSON.stringify(finalCampState)}`); assert(finalCampState.stagedSortiePrep === false, `Expected the non-battle final camp to avoid the three-stage sortie flow: ${JSON.stringify(finalCampState)}`);
assert(finalCampState.nextSortieBattleId === null, `Expected final camp to route to ending instead of a battle: ${JSON.stringify(finalCampState)}`); assert(finalCampState.nextSortieBattleId === null, `Expected final camp to route to ending instead of a battle: ${JSON.stringify(finalCampState)}`);
assert(
finalCampState.reportFormationEvaluation?.available === false &&
finalCampState.reportFormationEvaluation.open === false &&
finalCampState.reportFormationEvaluation.toggleButtonBounds === null &&
finalCampState.reportFormationEvaluation.presetCards?.length === 0,
`Expected a latest report without sortie performance to hide the camp evaluation instead of falling back to older history: ${JSON.stringify(finalCampState.reportFormationEvaluation)}`
);
assert( assert(
Array.isArray(finalCampState.sortieDeploymentPreview) && finalCampState.sortieDeploymentPreview.length === 0, Array.isArray(finalCampState.sortieDeploymentPreview) && finalCampState.sortieDeploymentPreview.length === 0,
`Expected final camp deployment preview to stay empty: ${JSON.stringify(finalCampState?.sortieDeploymentPreview)}` `Expected final camp deployment preview to stay empty: ${JSON.stringify(finalCampState?.sortieDeploymentPreview)}`
@@ -1723,6 +2037,80 @@ async function readBattleResultEvaluationControlPoint(page, control, presetId) {
return probe; return probe;
} }
async function readCampReportFormationEvaluationControlPoint(page, control, presetId) {
const probe = await page.evaluate(({ control, presetId }) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const evaluation = window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation;
const canvas = document.querySelector('canvas');
const presetCard = evaluation?.presetCards?.find((card) => card.id === presetId);
const bounds = control === 'toggle'
? evaluation?.toggleButtonBounds
: control === 'close'
? evaluation?.closeButtonBounds
: presetCard?.actionButtonBounds;
if (!scene || !canvas || !bounds) {
return {
ready: false,
control,
presetId: presetId ?? null,
evaluationOpen: evaluation?.open ?? false,
bounds: bounds ?? null
};
}
const canvasBounds = canvas.getBoundingClientRect();
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
ready: true,
control,
presetId: presetId ?? null,
evaluationOpen: evaluation?.open ?? false,
bounds,
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
};
}, { control, presetId });
assert(
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
`Expected a visible Phaser camp-report evaluation ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}`
);
return probe;
}
async function readCampContentTextControlPoint(page, label, occurrence = 0) {
const probe = await page.evaluate(({ label, occurrence }) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const canvas = document.querySelector('canvas');
const candidates = (scene?.contentObjects ?? [])
.filter((object) => object?.type === 'Text' && object?.text === label && object?.active && object?.visible)
.sort((left, right) => left.y - right.y || left.x - right.x);
const target = candidates[occurrence];
if (!scene || !canvas || !target) {
return { ready: false, label, occurrence, candidateCount: candidates.length };
}
const bounds = target.getBounds();
const canvasBounds = canvas.getBoundingClientRect();
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
ready: true,
label,
occurrence,
candidateCount: candidates.length,
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height
};
}, { label, occurrence });
assert(
probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y),
`Expected a visible Phaser camp text control "${label}" with a canvas-scaled click point: ${JSON.stringify(probe)}`
);
return probe;
}
async function readSortieComparisonActionPoint(page) { async function readSortieComparisonActionPoint(page) {
const probe = await page.evaluate(() => { const probe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');

View File

@@ -0,0 +1,195 @@
import { coreSortieSynergyRoles } from './sortieSynergy';
import type {
CampaignSortiePerformanceSnapshot,
CampaignSortiePreset
} from '../state/campaignState';
export type SortiePerformanceGrade = 'S' | 'A' | 'B' | 'C' | 'D';
export type SortiePerformanceContributionTotals = {
bonusDamage: number;
preventedDamage: number;
bonusHealing: number;
extendedBondTriggers: number;
counterplays: number;
};
export type SortiePerformanceEndUnit = {
id: string;
hp: number;
maxHp: number;
};
export type SortiePerformanceReview = {
score: number;
grade: SortiePerformanceGrade;
gradeColor: number;
gradeTextColor: string;
headline: string;
strengths: string[];
improvement: string;
objectiveAchieved: number;
objectiveTotal: number;
survivorCount: number;
deployedCount: number;
recoveryNeededCount: number;
totalDamage: number;
totalDamageTaken: number;
totalDefeats: number;
totalSupport: number;
roleCoverage: number;
activeBondCount: number;
contributionTotals: SortiePerformanceContributionTotals;
snapshot: CampaignSortiePerformanceSnapshot;
};
type SortiePerformanceReviewInput = {
outcome: 'victory' | 'defeat';
objectives: { achieved: boolean }[];
enemyCount: number;
activeBondCount: number;
endUnits: SortiePerformanceEndUnit[];
snapshot: CampaignSortiePerformanceSnapshot;
};
const gradeTone: Record<SortiePerformanceGrade, { color: number; text: string }> = {
S: { color: 0xd8b15f, text: '#fff0b5' },
A: { color: 0x59d18c, text: '#caffdc' },
B: { color: 0x72a7d8, text: '#d8ecff' },
C: { color: 0xc87552, text: '#ffd0bd' },
D: { color: 0x9a5b55, text: '#ffb6a6' }
};
const gradeHeadlines: Record<SortiePerformanceGrade, string> = {
S: '빈틈없이 목표를 완수한 모범 편성',
A: '안정적으로 전선을 지켜낸 우수 편성',
B: '강점이 분명하고 보완 여지가 있는 편성',
C: '역할과 생존 계획을 다시 다듬을 편성',
D: '재도전 전 전면적인 재편이 필요한 편성'
};
const roleLabels = {
front: '전열 방호',
flank: '돌파 선봉',
support: '후원 지휘'
} as const;
export function evaluateSortiePerformanceReview(input: SortiePerformanceReviewInput): SortiePerformanceReview {
const { snapshot } = input;
const unitsById = new Map(input.endUnits.map((unit) => [unit.id, unit]));
const deployedUnits = snapshot.selectedUnitIds
.map((unitId) => unitsById.get(unitId))
.filter((unit): unit is SortiePerformanceEndUnit => Boolean(unit));
const objectiveAchieved = input.objectives.filter((objective) => objective.achieved).length;
const survivorCount = deployedUnits.filter((unit) => unit.hp > 0).length;
const recoveryNeededCount = deployedUnits.filter((unit) => unit.hp > 0 && unit.hp < unit.maxHp).length;
const totalDamage = snapshot.unitStats.reduce((sum, stats) => sum + stats.damageDealt, 0);
const totalDamageTaken = snapshot.unitStats.reduce((sum, stats) => sum + stats.damageTaken, 0);
const totalDefeats = snapshot.unitStats.reduce((sum, stats) => sum + stats.defeats, 0);
const totalSupport = snapshot.unitStats.reduce((sum, stats) => sum + stats.support, 0);
const roleCoverage = coreSortieSynergyRoles.filter((role) =>
snapshot.selectedUnitIds.some((unitId) => snapshot.formationAssignments[unitId] === role)
).length;
const contributionTotals = snapshot.unitStats.reduce<SortiePerformanceContributionTotals>((totals, stats) => {
totals.bonusDamage += stats.sortieBonusDamage;
totals.preventedDamage += stats.sortiePreventedDamage;
totals.bonusHealing += stats.sortieBonusHealing;
totals.extendedBondTriggers += stats.sortieExtendedBondTriggers;
totals.counterplays += stats.intentDefeats + stats.intentLineBreaks + stats.intentGuardSuccesses;
return totals;
}, {
bonusDamage: 0,
preventedDamage: 0,
bonusHealing: 0,
extendedBondTriggers: 0,
counterplays: 0
});
const objectiveRate = input.objectives.length > 0 ? objectiveAchieved / input.objectives.length : 1;
const survivalRate = deployedUnits.length > 0 ? survivorCount / deployedUnits.length : 0;
const defeatRate = input.enemyCount > 0 ? Math.min(1, totalDefeats / input.enemyCount) : 1;
const contributionScore = contributionTotals.bonusDamage + contributionTotals.preventedDamage +
contributionTotals.bonusHealing + contributionTotals.extendedBondTriggers * 4 + contributionTotals.counterplays * 3;
const score = Math.min(100, Math.max(0, Math.round(
(input.outcome === 'victory' ? 35 : 10) +
objectiveRate * 25 +
survivalRate * 20 +
defeatRate * 10 +
(roleCoverage / coreSortieSynergyRoles.length) * 5 +
Math.min(5, input.activeBondCount * 2) +
Math.min(5, contributionScore / 12)
)));
const grade: SortiePerformanceGrade = score >= 90 ? 'S' : score >= 75 ? 'A' : score >= 60 ? 'B' : score >= 45 ? 'C' : 'D';
const strengths = [
objectiveAchieved === input.objectives.length && input.objectives.length > 0 ? `목표 ${objectiveAchieved}/${input.objectives.length} 완수` : '',
survivorCount === deployedUnits.length && deployedUnits.length > 0 ? '출전 전원 생존' : '',
roleCoverage === coreSortieSynergyRoles.length ? '핵심 역할 3/3 충족' : '',
input.activeBondCount > 0 ? `활성 공명 ${input.activeBondCount}` : '',
contributionScore > 0 ? `편성 효과 기여 ${contributionScore}` : '',
totalDefeats > 0 ? `${totalDefeats}명 격파` : ''
].filter(Boolean).slice(0, 3);
const missingRoleLabels = coreSortieSynergyRoles
.filter((role) => !snapshot.selectedUnitIds.some((unitId) => snapshot.formationAssignments[unitId] === role))
.map((role) => roleLabels[role]);
const improvement = input.outcome === 'defeat'
? '패배 조건을 먼저 차단하고 필수 무장의 생존 동선을 확보하십시오.'
: survivorCount < deployedUnits.length
? `퇴각 ${deployedUnits.length - survivorCount}명 · 전열 방호와 보급 배정을 강화하십시오.`
: missingRoleLabels.length > 0
? `${missingRoleLabels.join('·')} 공백 · 역할을 다시 배정하십시오.`
: objectiveAchieved < input.objectives.length
? `미달 목표 ${input.objectives.length - objectiveAchieved}개 · 기동 담당과 동선을 조정하십시오.`
: recoveryNeededCount > 0 && totalSupport === 0
? `회복 필요 ${recoveryNeededCount}명 · 후원 담당의 회복·보급 사용을 늘리십시오.`
: '현재 명단과 역할은 편성책에 기록해 다시 활용할 가치가 있습니다.';
return {
score,
grade,
gradeColor: gradeTone[grade].color,
gradeTextColor: gradeTone[grade].text,
headline: gradeHeadlines[grade],
strengths: strengths.length > 0 ? strengths : ['전투 기록 확보'],
improvement,
objectiveAchieved,
objectiveTotal: input.objectives.length,
survivorCount,
deployedCount: deployedUnits.length,
recoveryNeededCount,
totalDamage,
totalDamageTaken,
totalDefeats,
totalSupport,
roleCoverage,
activeBondCount: input.activeBondCount,
contributionTotals,
snapshot
};
}
export function sortiePerformanceRoleContributionLine(review: SortiePerformanceReview) {
const statsById = new Map(review.snapshot.unitStats.map((stats) => [stats.unitId, stats]));
return coreSortieSynergyRoles.map((role) => {
const roleStats = review.snapshot.selectedUnitIds
.filter((unitId) => review.snapshot.formationAssignments[unitId] === role)
.map((unitId) => statsById.get(unitId))
.filter((stats): stats is NonNullable<typeof stats> => Boolean(stats));
const value = role === 'front'
? roleStats.reduce((sum, stats) => sum + stats.sortiePreventedDamage + stats.sortieBonusDamage, 0)
: role === 'flank'
? roleStats.reduce((sum, stats) => sum + stats.sortieBonusDamage, 0)
: roleStats.reduce((sum, stats) => sum + stats.sortieBonusDamage + stats.sortieBonusHealing, 0);
const label = role === 'front' ? '전열' : role === 'flank' ? '돌파' : '후원';
return `${label} ${value}`;
}).join(' · ');
}
export function sortiePerformanceMatchesPreset(
snapshot: CampaignSortiePerformanceSnapshot,
preset?: CampaignSortiePreset
) {
return Boolean(
preset &&
JSON.stringify(preset.selectedUnitIds) === JSON.stringify(snapshot.selectedUnitIds) &&
snapshot.selectedUnitIds.every((unitId) => preset.formationAssignments[unitId] === snapshot.formationAssignments[unitId])
);
}

View File

@@ -49,6 +49,12 @@ import {
sortieBondBonusForLevel, sortieBondBonusForLevel,
sortieRoleEffectSummaries sortieRoleEffectSummaries
} from '../data/sortieSynergy'; } from '../data/sortieSynergy';
import {
evaluateSortiePerformanceReview,
sortiePerformanceMatchesPreset,
sortiePerformanceRoleContributionLine,
type SortiePerformanceReview
} from '../data/sortiePerformanceReview';
import { import {
campaignSortiePresetIds, campaignSortiePresetIds,
campaignSaveSlotCount, campaignSaveSlotCount,
@@ -1277,39 +1283,6 @@ type UnitBattleStats = {
intentGuardSuccesses: number; intentGuardSuccesses: number;
}; };
type ResultFormationGrade = 'S' | 'A' | 'B' | 'C' | 'D';
type ResultSortieContributionTotals = {
bonusDamage: number;
preventedDamage: number;
bonusHealing: number;
extendedBondTriggers: number;
counterplays: number;
};
type ResultFormationReview = {
score: number;
grade: ResultFormationGrade;
gradeColor: number;
gradeTextColor: string;
headline: string;
strengths: string[];
improvement: string;
objectiveAchieved: number;
objectiveTotal: number;
survivorCount: number;
deployedCount: number;
recoveryNeededCount: number;
totalDamage: number;
totalDamageTaken: number;
totalDefeats: number;
totalSupport: number;
roleCoverage: number;
activeBondCount: number;
contributionTotals: ResultSortieContributionTotals;
snapshot: CampaignSortiePerformanceSnapshot;
};
type ResultFormationReviewView = { type ResultFormationReviewView = {
closeButton: Phaser.GameObjects.Rectangle; closeButton: Phaser.GameObjects.Rectangle;
presetButtons: Partial<Record<CampaignSortiePresetId, Phaser.GameObjects.Rectangle>>; presetButtons: Partial<Record<CampaignSortiePresetId, Phaser.GameObjects.Rectangle>>;
@@ -10777,125 +10750,21 @@ export class BattleScene extends Phaser.Scene {
private resultFormationReview( private resultFormationReview(
outcome: BattleOutcome, outcome: BattleOutcome,
snapshot: CampaignSortiePerformanceSnapshot snapshot: CampaignSortiePerformanceSnapshot
): ResultFormationReview { ): SortiePerformanceReview {
const unitsById = new Map(battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => [unit.id, unit])); return evaluateSortiePerformanceReview({
const deployedUnits = snapshot.selectedUnitIds outcome,
.map((unitId) => unitsById.get(unitId)) objectives: this.resultObjectives(outcome),
.filter((unit): unit is UnitData => Boolean(unit)); enemyCount: battleUnits.filter((unit) => unit.faction === 'enemy').length,
const objectives = this.resultObjectives(outcome); activeBondCount: this.activeSortieBonds().length,
const objectiveAchieved = objectives.filter((objective) => objective.achieved).length; endUnits: battleUnits
const survivorCount = deployedUnits.filter((unit) => unit.hp > 0).length; .filter((unit) => unit.faction === 'ally')
const recoveryNeededCount = deployedUnits.filter((unit) => unit.hp > 0 && unit.hp < unit.maxHp).length; .map((unit) => ({ id: unit.id, hp: unit.hp, maxHp: unit.maxHp })),
const totalDamage = snapshot.unitStats.reduce((sum, stats) => sum + stats.damageDealt, 0);
const totalDamageTaken = snapshot.unitStats.reduce((sum, stats) => sum + stats.damageTaken, 0);
const totalDefeats = snapshot.unitStats.reduce((sum, stats) => sum + stats.defeats, 0);
const totalSupport = snapshot.unitStats.reduce((sum, stats) => sum + stats.support, 0);
const roleCoverage = coreSortieSynergyRoles.filter((role) =>
snapshot.selectedUnitIds.some((unitId) => snapshot.formationAssignments[unitId] === role)
).length;
const activeBondCount = this.activeSortieBonds().length;
const contributionTotals = this.sortieContributionTotals();
const objectiveRate = objectives.length > 0 ? objectiveAchieved / objectives.length : 1;
const survivalRate = deployedUnits.length > 0 ? survivorCount / deployedUnits.length : 0;
const enemyCount = battleUnits.filter((unit) => unit.faction === 'enemy').length;
const defeatRate = enemyCount > 0 ? Math.min(1, totalDefeats / enemyCount) : 1;
const contributionScore = contributionTotals.bonusDamage + contributionTotals.preventedDamage +
contributionTotals.bonusHealing + contributionTotals.extendedBondTriggers * 4 + contributionTotals.counterplays * 3;
const score = Phaser.Math.Clamp(Math.round(
(outcome === 'victory' ? 35 : 10) +
objectiveRate * 25 +
survivalRate * 20 +
defeatRate * 10 +
(roleCoverage / coreSortieSynergyRoles.length) * 5 +
Math.min(5, activeBondCount * 2) +
Math.min(5, contributionScore / 12)
), 0, 100);
const grade: ResultFormationGrade = score >= 90 ? 'S' : score >= 75 ? 'A' : score >= 60 ? 'B' : score >= 45 ? 'C' : 'D';
const gradeTone: Record<ResultFormationGrade, { color: number; text: string }> = {
S: { color: 0xd8b15f, text: '#fff0b5' },
A: { color: 0x59d18c, text: '#caffdc' },
B: { color: 0x72a7d8, text: '#d8ecff' },
C: { color: 0xc87552, text: '#ffd0bd' },
D: { color: 0x9a5b55, text: '#ffb6a6' }
};
const headlines: Record<ResultFormationGrade, string> = {
S: '빈틈없이 목표를 완수한 모범 편성',
A: '안정적으로 전선을 지켜낸 우수 편성',
B: '강점이 분명하고 보완 여지가 있는 편성',
C: '역할과 생존 계획을 다시 다듬을 편성',
D: '재도전 전 전면적인 재편이 필요한 편성'
};
const strengths = [
objectiveAchieved === objectives.length && objectives.length > 0 ? `목표 ${objectiveAchieved}/${objectives.length} 완수` : '',
survivorCount === deployedUnits.length && deployedUnits.length > 0 ? '출전 전원 생존' : '',
roleCoverage === coreSortieSynergyRoles.length ? '핵심 역할 3/3 충족' : '',
activeBondCount > 0 ? `활성 공명 ${activeBondCount}` : '',
contributionScore > 0 ? `편성 효과 기여 ${contributionScore}` : '',
totalDefeats > 0 ? `${totalDefeats}명 격파` : ''
].filter(Boolean).slice(0, 3);
const missingRoleLabels = coreSortieSynergyRoles
.filter((role) => !snapshot.selectedUnitIds.some((unitId) => snapshot.formationAssignments[unitId] === role))
.map((role) => sortieRoleEffects[role]?.label ?? role);
const improvement = outcome === 'defeat'
? '패배 조건을 먼저 차단하고 필수 무장의 생존 동선을 확보하십시오.'
: survivorCount < deployedUnits.length
? `퇴각 ${deployedUnits.length - survivorCount}명 · 전열 방호와 보급 배정을 강화하십시오.`
: missingRoleLabels.length > 0
? `${missingRoleLabels.join('·')} 공백 · 역할을 다시 배정하십시오.`
: objectiveAchieved < objectives.length
? `미달 목표 ${objectives.length - objectiveAchieved}개 · 기동 담당과 동선을 조정하십시오.`
: recoveryNeededCount > 0 && totalSupport === 0
? `회복 필요 ${recoveryNeededCount}명 · 후원 담당의 회복·보급 사용을 늘리십시오.`
: '현재 명단과 역할은 편성책에 기록해 다시 활용할 가치가 있습니다.';
return {
score,
grade,
gradeColor: gradeTone[grade].color,
gradeTextColor: gradeTone[grade].text,
headline: headlines[grade],
strengths: strengths.length > 0 ? strengths : ['전투 기록 확보'],
improvement,
objectiveAchieved,
objectiveTotal: objectives.length,
survivorCount,
deployedCount: deployedUnits.length,
recoveryNeededCount,
totalDamage,
totalDamageTaken,
totalDefeats,
totalSupport,
roleCoverage,
activeBondCount,
contributionTotals,
snapshot snapshot
}; });
}
private resultFormationRoleContributionLine(review: ResultFormationReview) {
const statsById = new Map(review.snapshot.unitStats.map((stats) => [stats.unitId, stats]));
return coreSortieSynergyRoles.map((role) => {
const roleStats = review.snapshot.selectedUnitIds
.filter((unitId) => review.snapshot.formationAssignments[unitId] === role)
.map((unitId) => statsById.get(unitId))
.filter((stats): stats is NonNullable<typeof stats> => Boolean(stats));
const value = role === 'front'
? roleStats.reduce((sum, stats) => sum + stats.sortiePreventedDamage + stats.sortieBonusDamage, 0)
: role === 'flank'
? roleStats.reduce((sum, stats) => sum + stats.sortieBonusDamage, 0)
: roleStats.reduce((sum, stats) => sum + stats.sortieBonusDamage + stats.sortieBonusHealing, 0);
const label = role === 'front' ? '전열' : role === 'flank' ? '돌파' : '후원';
return `${label} ${value}`;
}).join(' · ');
} }
private resultFormationSnapshotMatchesPreset(presetId: CampaignSortiePresetId, snapshot: CampaignSortiePerformanceSnapshot) { private resultFormationSnapshotMatchesPreset(presetId: CampaignSortiePresetId, snapshot: CampaignSortiePerformanceSnapshot) {
const preset = getCampaignState().sortieFormationPresets[presetId]; return sortiePerformanceMatchesPreset(snapshot, getCampaignState().sortieFormationPresets[presetId]);
return Boolean(
preset &&
JSON.stringify(preset.selectedUnitIds) === JSON.stringify(snapshot.selectedUnitIds) &&
snapshot.selectedUnitIds.every((unitId) => preset.formationAssignments[unitId] === snapshot.formationAssignments[unitId])
);
} }
private resultFormationSourcePresetIds(snapshot: CampaignSortiePerformanceSnapshot) { private resultFormationSourcePresetIds(snapshot: CampaignSortiePerformanceSnapshot) {
@@ -10980,7 +10849,7 @@ export class BattleScene extends Phaser.Scene {
this.renderResultFormationAnalysisPanel( this.renderResultFormationAnalysisPanel(
'역할·공명 기여', '역할·공명 기여',
[ [
`${this.resultFormationRoleContributionLine(review)} · 역할 ${review.roleCoverage}/3`, `${sortiePerformanceRoleContributionLine(review)} · 역할 ${review.roleCoverage}/3`,
`공명 ${review.activeBondCount}개 · 연장 ${review.contributionTotals.extendedBondTriggers}회 · 파훼 ${review.contributionTotals.counterplays}` `공명 ${review.activeBondCount}개 · 연장 ${review.contributionTotals.extendedBondTriggers}회 · 파훼 ${review.contributionTotals.counterplays}`
], ],
x + 624, x + 624,

View File

@@ -38,6 +38,12 @@ import {
type SortieSynergyComparison, type SortieSynergyComparison,
type SortieSynergySnapshot type SortieSynergySnapshot
} from '../data/sortieSynergy'; } from '../data/sortieSynergy';
import {
evaluateSortiePerformanceReview,
sortiePerformanceMatchesPreset,
sortiePerformanceRoleContributionLine,
type SortiePerformanceReview
} from '../data/sortiePerformanceReview';
import { import {
caoBreakRecruitBonds, caoBreakRecruitBonds,
caoBreakRecruitUnits, caoBreakRecruitUnits,
@@ -132,6 +138,7 @@ import {
type CampaignReserveTrainingFocusId, type CampaignReserveTrainingFocusId,
type CampaignSortieItemAssignments, type CampaignSortieItemAssignments,
type CampaignSortieFormationPresets, type CampaignSortieFormationPresets,
type CampaignSortiePerformanceSnapshot,
type CampaignSortiePresetId, type CampaignSortiePresetId,
type FirstBattleReport type FirstBattleReport
} from '../state/campaignState'; } from '../state/campaignState';
@@ -435,6 +442,11 @@ type SortiePresetBrowserView = {
cards: Partial<Record<CampaignSortiePresetId, SortiePresetCardView>>; cards: Partial<Record<CampaignSortiePresetId, SortiePresetCardView>>;
}; };
type ReportFormationReviewView = {
closeButton: Phaser.GameObjects.Rectangle;
presetButtons: Partial<Record<CampaignSortiePresetId, Phaser.GameObjects.Rectangle>>;
};
type SortieComparisonAction = { type SortieComparisonAction = {
kind: 'confirm-swap' | 'undo-swap' | 'confirm-preset' | 'undo-preset'; kind: 'confirm-swap' | 'undo-swap' | 'confirm-preset' | 'undo-preset';
label: string; label: string;
@@ -10894,6 +10906,12 @@ export class CampScene extends Phaser.Scene {
private saveSlotObjects: Phaser.GameObjects.GameObject[] = []; private saveSlotObjects: Phaser.GameObjects.GameObject[] = [];
private saveSlotConfirmObjects: Phaser.GameObjects.GameObject[] = []; private saveSlotConfirmObjects: Phaser.GameObjects.GameObject[] = [];
private pendingSaveSlot?: number; private pendingSaveSlot?: number;
private reportFormationReviewObjects: Phaser.GameObjects.GameObject[] = [];
private reportFormationReviewVisible = false;
private reportFormationReviewFeedback = '';
private reportFormationPresetOverwriteConfirmId?: CampaignSortiePresetId;
private reportFormationReviewToggleButton?: Phaser.GameObjects.Rectangle;
private reportFormationReviewView?: ReportFormationReviewView;
private tabButtons: CampTabButtonView[] = []; private tabButtons: CampTabButtonView[] = [];
private visitedTabs = new Set<CampTab>(); private visitedTabs = new Set<CampTab>();
private selectedSortieUnitIds: string[] = []; private selectedSortieUnitIds: string[] = [];
@@ -10944,6 +10962,12 @@ export class CampScene extends Phaser.Scene {
this.saveSlotObjects = []; this.saveSlotObjects = [];
this.saveSlotConfirmObjects = []; this.saveSlotConfirmObjects = [];
this.pendingSaveSlot = undefined; this.pendingSaveSlot = undefined;
this.reportFormationReviewObjects = [];
this.reportFormationReviewVisible = false;
this.reportFormationReviewFeedback = '';
this.reportFormationPresetOverwriteConfirmId = undefined;
this.reportFormationReviewToggleButton = undefined;
this.reportFormationReviewView = undefined;
this.tabButtons = []; this.tabButtons = [];
this.visitedTabs = new Set(['status']); this.visitedTabs = new Set(['status']);
this.campaign = getCampaignState(); this.campaign = getCampaignState();
@@ -12350,6 +12374,12 @@ export class CampScene extends Phaser.Scene {
return; return;
} }
if (this.reportFormationReviewVisible) {
soundDirector.playSelect();
this.hideReportFormationReview();
return;
}
if (this.sortieObjects.length > 0) { if (this.sortieObjects.length > 0) {
soundDirector.playSelect(); soundDirector.playSelect();
this.hideSortiePrep(); this.hideSortiePrep();
@@ -12434,6 +12464,7 @@ export class CampScene extends Phaser.Scene {
} }
private render() { private render() {
this.hideReportFormationReview();
this.hideCampSaveSlotPanel(); this.hideCampSaveSlotPanel();
this.clearContent(); this.clearContent();
this.visitedTabs.add(this.activeTab); this.visitedTabs.add(this.activeTab);
@@ -12471,6 +12502,7 @@ export class CampScene extends Phaser.Scene {
private showSortiePrep() { private showSortiePrep() {
const wasVisible = this.sortieObjects.length > 0; const wasVisible = this.sortieObjects.length > 0;
this.hideReportFormationReview();
this.hideCampSaveSlotPanel(); this.hideCampSaveSlotPanel();
this.hideSortiePrep(false); this.hideSortiePrep(false);
this.campaign = getCampaignState(); this.campaign = getCampaignState();
@@ -16605,6 +16637,7 @@ export class CampScene extends Phaser.Scene {
return; return;
} }
this.reportFormationReviewToggleButton = undefined;
const x = 42; const x = 42;
const y = 590; const y = 590;
const bg = this.track(this.add.rectangle(x, y, 1180, 84, 0x101820, 0.86)); const bg = this.track(this.add.rectangle(x, y, 1180, 84, 0x101820, 0.86));
@@ -16615,7 +16648,7 @@ export class CampScene extends Phaser.Scene {
this.add.text( this.add.text(
x + 18, x + 18,
y + 39, y + 39,
`MVP ${report.mvp?.name ?? '-'} · 전투 보상 ${report.rewardGold} · 전리품 ${report.itemRewards.join(', ') || '없음'}`, this.compactText(`MVP ${report.mvp?.name ?? '-'} · 전투 보상 ${report.rewardGold} · 전리품 ${report.itemRewards.join(', ') || '없음'}`, 66),
this.textStyle(14, '#d4dce6') this.textStyle(14, '#d4dce6')
) )
); );
@@ -16623,10 +16656,80 @@ export class CampScene extends Phaser.Scene {
this.add.text( this.add.text(
x + 18, x + 18,
y + 62, y + 62,
this.reportCampaignRewardLine(report), this.compactText(this.reportCampaignRewardLine(report), 70),
this.textStyle(12, '#d8b15f', true) this.textStyle(12, '#d8b15f', true)
) )
); );
const review = this.reportFormationReview();
if (review) {
this.reportFormationReviewToggleButton = this.addReportFormationReviewToggleButton(
`편성 평가 ${review.grade}`,
x + 1084,
y + 42,
158,
() => this.showReportFormationReview()
);
}
}
private reportFormationReview(): SortiePerformanceReview | undefined {
const report = this.report;
if (!report || (this.campaign?.latestBattleId && report.battleId !== this.campaign.latestBattleId)) {
return undefined;
}
const settlement = this.campaign?.battleHistory[report.battleId];
const snapshot = settlement?.sortiePerformance ?? report.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 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 activeBondCount = report.bonds.filter((bond) => bond.unitIds.every((unitId) => selectedIds.has(unitId))).length;
return evaluateSortiePerformanceReview({
outcome: settlement?.outcome ?? report.outcome,
objectives: settlement?.objectives ?? report.objectives,
enemyCount: report.totalEnemies,
activeBondCount,
endUnits,
snapshot
});
}
private reportFormationSourcePresetIds(snapshot: CampaignSortiePerformanceSnapshot) {
const presets = this.sortieFormationPresets();
return campaignSortiePresetIds.filter((presetId) => sortiePerformanceMatchesPreset(snapshot, presets[presetId]));
}
private addReportFormationReviewToggleButton(
label: string,
x: number,
y: number,
width: number,
action: () => void
) {
const background = this.track(this.add.rectangle(x, y, width, 36, 0x1a2630, 0.98));
background.setStrokeStyle(1, palette.gold, 0.82);
background.setInteractive({ useHandCursor: true });
background.on('pointerover', () => background.setFillStyle(0x283947, 1).setStrokeStyle(1, palette.gold, 0.98));
background.on('pointerout', () => background.setFillStyle(0x1a2630, 0.98).setStrokeStyle(1, palette.gold, 0.82));
background.on('pointerdown', () => {
soundDirector.playSelect();
action();
});
this.track(this.add.text(x, y, label, this.textStyle(14, '#f2e3bf', true))).setOrigin(0.5);
return background;
} }
private reportCampaignRewardLine(report: FirstBattleReport) { private reportCampaignRewardLine(report: FirstBattleReport) {
@@ -16648,6 +16751,314 @@ export class CampScene extends Phaser.Scene {
return parts.length > 0 ? `캠페인 반영: ${parts.join(' · ')}` : '캠페인 보상: 추가 해금 없음'; return parts.length > 0 ? `캠페인 반영: ${parts.join(' · ')}` : '캠페인 보상: 추가 해금 없음';
} }
private showReportFormationReview() {
if (!this.reportFormationReview()) {
return;
}
this.hideCampSaveSlotPanel();
this.reportFormationReviewVisible = true;
this.renderReportFormationReview();
}
private renderReportFormationReview() {
const review = this.reportFormationReview();
const report = this.report;
if (!review || !report) {
this.hideReportFormationReview();
return;
}
this.reportFormationReviewObjects.forEach((object) => object.destroy());
this.reportFormationReviewObjects = [];
this.reportFormationReviewView = undefined;
const depth = 52;
const width = 980;
const height = 420;
const x = Math.floor((this.scale.width - width) / 2);
const y = 150;
const shade = this.trackReportFormationReview(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.76));
shade.setOrigin(0);
shade.setDepth(depth);
shade.setInteractive();
const background = this.trackReportFormationReview(this.add.rectangle(x, y, width, height, 0x0b1219, 0.995));
background.setOrigin(0);
background.setDepth(depth + 1);
background.setStrokeStyle(2, review.gradeColor, 0.9);
background.setInteractive();
this.trackReportFormationReview(this.add.text(x + 18, y + 12, '최근 전투 편성 평가', this.textStyle(20, '#f2e3bf', true)))
.setDepth(depth + 2);
const closeButton = this.addReportFormationReviewButton(
'전투 보고로',
x + width - 112,
y + 9,
96,
28,
true,
false,
() => {
soundDirector.playSelect();
this.hideReportFormationReview();
},
depth + 3
);
const gradeSeal = this.trackReportFormationReview(this.add.circle(x + 58, y + 82, 34, review.gradeColor, 0.22));
gradeSeal.setDepth(depth + 2);
gradeSeal.setStrokeStyle(2, review.gradeColor, 0.96);
this.trackReportFormationReview(this.add.text(x + 58, y + 82, review.grade, this.textStyle(34, review.gradeTextColor, true)))
.setOrigin(0.5)
.setDepth(depth + 3);
this.trackReportFormationReview(
this.add.text(
x + 104,
y + 49,
this.compactText(`${report.battleTitle} · ${review.headline} · ${review.score}`, 52),
this.textStyle(17, review.gradeTextColor, true)
)
).setDepth(depth + 2);
this.trackReportFormationReview(this.add.text(x + 104, y + 75, `강점 · ${review.strengths.join(' · ')}`, this.textStyle(11, '#d4dce6', true)))
.setDepth(depth + 2);
this.trackReportFormationReview(this.add.text(x + 104, y + 96, `보완 · ${this.compactText(review.improvement, 70)}`, this.textStyle(11, '#ffdf7b', true)))
.setDepth(depth + 2);
const metricY = y + 126;
const metricGap = 10;
const metricWidth = Math.floor((width - 36 - metricGap * 3) / 4);
const metrics = [
{ label: '목표 달성', value: `${review.objectiveAchieved}/${review.objectiveTotal}` },
{ label: '생존·회복', value: `${review.survivorCount}/${review.deployedCount} · 회복 ${review.recoveryNeededCount}` },
{ label: '전과', value: `피해 ${review.totalDamage} · 격파 ${review.totalDefeats}` },
{ label: '지원·피격', value: `지원 ${review.totalSupport} · 피격 ${review.totalDamageTaken}` }
];
metrics.forEach((metric, index) => {
this.renderReportFormationMetric(metric.label, metric.value, x + 18 + index * (metricWidth + metricGap), metricY, metricWidth, depth + 2);
});
const analysisY = y + 184;
this.renderReportFormationAnalysisPanel(
'전술 판단',
[
`강점 · ${review.strengths.join(' · ')}`,
`보완 · ${this.compactText(review.improvement, 62)}`
],
x + 18,
analysisY,
596,
72,
review.gradeColor,
depth + 2
);
this.renderReportFormationAnalysisPanel(
'역할·공명 기여',
[
`${sortiePerformanceRoleContributionLine(review)} · 역할 ${review.roleCoverage}/3`,
`공명 ${review.activeBondCount}개 · 연장 ${review.contributionTotals.extendedBondTriggers}회 · 파훼 ${review.contributionTotals.counterplays}`
],
x + 624,
analysisY,
338,
72,
palette.blue,
depth + 2
);
const rosterY = y + 264;
const hiddenUnitCount = Math.max(0, review.snapshot.selectedUnitIds.length - 8);
this.trackReportFormationReview(
this.add.text(
x + 18,
rosterY,
`출전 명단·역할${hiddenUnitCount > 0 ? ` · 외 ${hiddenUnitCount}` : ''}`,
this.textStyle(13, '#f2e3bf', true)
)
).setDepth(depth + 2);
const visibleUnitIds = review.snapshot.selectedUnitIds.slice(0, 8);
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 roleLabel = role === 'front' ? '전' : role === 'flank' ? '돌' : role === 'support' ? '후' : '예';
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)))
.setDepth(depth + 3);
});
const presetTitleY = y + 337;
this.trackReportFormationReview(this.add.text(x + 18, presetTitleY, '이 전투 편성을 편성책에 반영', this.textStyle(13, '#f2e3bf', true)))
.setDepth(depth + 2);
this.trackReportFormationReview(this.add.text(x + 218, presetTitleY + 2, '명단·역할만 저장 · 장비·보급 제외', this.textStyle(10, '#9fb0bf', true)))
.setDepth(depth + 2);
const presets = this.sortieFormationPresets();
const sourcePresetIds = this.reportFormationSourcePresetIds(review.snapshot);
const presetButtons: ReportFormationReviewView['presetButtons'] = {};
const presetGap = 10;
const presetWidth = Math.floor((width - 36 - presetGap * 2) / 3);
sortiePresetDefinitions.forEach((definition, index) => {
const preset = presets[definition.id];
const matching = sourcePresetIds.includes(definition.id);
const confirming = this.reportFormationPresetOverwriteConfirmId === definition.id;
const cardX = x + 18 + index * (presetWidth + presetGap);
const label = matching
? `${definition.label} · 현재와 동일`
: preset
? `${definition.label} · 저장 ${preset.selectedUnitIds.length}`
: `${definition.label} · 비어 있음`;
const actionLabel = matching ? '저장됨' : confirming ? '갱신 확정' : preset ? '갱신' : '저장';
presetButtons[definition.id] = this.addReportFormationReviewButton(
`${label} ${actionLabel}`,
cardX,
presetTitleY + 24,
presetWidth,
42,
!matching,
confirming || matching,
() => this.requestReportFormationPresetUpdate(definition.id),
depth + 3,
definition.accent
);
});
const feedback = this.reportFormationReviewFeedback || '전투 기록을 비교한 뒤 저장할 편성책을 선택하십시오.';
const feedbackColor = this.reportFormationPresetOverwriteConfirmId
? '#ffdf7b'
: this.reportFormationReviewFeedback
? '#a8ffd0'
: '#9fb0bf';
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 };
}
private hideReportFormationReview(resetFeedback = true) {
this.reportFormationReviewObjects.forEach((object) => object.destroy());
this.reportFormationReviewObjects = [];
this.reportFormationReviewVisible = false;
this.reportFormationPresetOverwriteConfirmId = undefined;
this.reportFormationReviewView = undefined;
if (resetFeedback) {
this.reportFormationReviewFeedback = '';
}
}
private requestReportFormationPresetUpdate(presetId: CampaignSortiePresetId) {
const review = this.reportFormationReview();
if (!review) {
this.hideReportFormationReview();
return;
}
const presets = this.sortieFormationPresets();
const existing = presets[presetId];
const definition = this.sortiePresetDefinition(presetId);
if (sortiePerformanceMatchesPreset(review.snapshot, existing)) {
this.reportFormationReviewFeedback = `${definition.label} 편성책이 이미 이 전투 명단·역할과 같습니다.`;
this.renderReportFormationReview();
return;
}
if (existing && this.reportFormationPresetOverwriteConfirmId !== presetId) {
this.reportFormationPresetOverwriteConfirmId = presetId;
this.reportFormationReviewFeedback = `${definition.label} 편성책을 갱신하려면 한 번 더 누르십시오.`;
soundDirector.playSelect();
this.renderReportFormationReview();
return;
}
const campaign = getCampaignState();
campaign.sortieFormationPresets = {
...campaign.sortieFormationPresets,
[presetId]: {
selectedUnitIds: [...review.snapshot.selectedUnitIds],
formationAssignments: { ...review.snapshot.formationAssignments }
}
};
this.campaign = saveCampaignState(campaign);
this.report = this.campaign.firstBattleReport ?? this.report;
this.reportFormationPresetOverwriteConfirmId = undefined;
this.reportFormationReviewFeedback = `${definition.label} 편성책 갱신 완료 · 장비·보급은 저장하지 않았습니다.`;
soundDirector.playSelect();
this.renderReportFormationReview();
}
private renderReportFormationMetric(label: string, value: string, x: number, y: number, width: number, depth: number) {
const background = this.trackReportFormationReview(this.add.rectangle(x, y, width, 48, 0x111c26, 0.96));
background.setOrigin(0);
background.setDepth(depth);
background.setStrokeStyle(1, palette.gold, 0.38);
this.trackReportFormationReview(this.add.text(x + 10, y + 7, label, this.textStyle(10, '#9fb0bf', true))).setDepth(depth + 1);
this.trackReportFormationReview(this.add.text(x + width - 10, y + 25, this.compactText(value, 24), this.textStyle(12, '#f2e3bf', true)))
.setOrigin(1, 0)
.setDepth(depth + 1);
}
private renderReportFormationAnalysisPanel(
title: string,
lines: string[],
x: number,
y: number,
width: number,
height: number,
accent: number,
depth: number
) {
const background = this.trackReportFormationReview(this.add.rectangle(x, y, width, height, 0x101820, 0.96));
background.setOrigin(0);
background.setDepth(depth);
background.setStrokeStyle(1, accent, 0.5);
this.trackReportFormationReview(this.add.text(x + 12, y + 8, title, this.textStyle(12, '#f2e3bf', true))).setDepth(depth + 1);
lines.slice(0, 2).forEach((line, index) => {
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);
});
}
private addReportFormationReviewButton(
label: string,
x: number,
y: number,
width: number,
height: number,
enabled: boolean,
active: boolean,
action: () => void,
depth: number,
accent: number = palette.gold
) {
const fill = active ? 0x263a2d : enabled ? 0x1a2630 : 0x111820;
const button = this.trackReportFormationReview(this.add.rectangle(x, y, width, height, fill, enabled || active ? 0.98 : 0.6));
button.setOrigin(0);
button.setDepth(depth);
button.setStrokeStyle(1, active ? palette.green : enabled ? accent : 0x53606c, active ? 0.82 : enabled ? 0.62 : 0.3);
if (enabled) {
button.setInteractive({ useHandCursor: true });
button.on('pointerover', () => button.setFillStyle(active ? 0x365044 : 0x283947, 1).setStrokeStyle(1, palette.gold, 0.94));
button.on('pointerout', () => button.setFillStyle(fill, 0.98).setStrokeStyle(1, active ? palette.green : accent, active ? 0.82 : 0.62));
button.on('pointerdown', action);
}
this.trackReportFormationReview(this.add.text(x + width / 2, y + height / 2, this.compactText(label, 28), this.textStyle(10, enabled || active ? '#f2e3bf' : '#77818c', true)))
.setOrigin(0.5)
.setDepth(depth + 1);
return button;
}
private trackReportFormationReview<T extends Phaser.GameObjects.GameObject>(object: T) {
this.reportFormationReviewObjects.push(object);
return object;
}
private renderProgressPanel() { private renderProgressPanel() {
const x = 394; const x = 394;
const y = 120; const y = 120;
@@ -18220,6 +18631,7 @@ export class CampScene extends Phaser.Scene {
private clearContent() { private clearContent() {
this.contentObjects.forEach((object) => object.destroy()); this.contentObjects.forEach((object) => object.destroy());
this.contentObjects = []; this.contentObjects = [];
this.reportFormationReviewToggleButton = undefined;
} }
private sortieDeploymentPreviewDebug() { private sortieDeploymentPreviewDebug() {
@@ -18256,9 +18668,66 @@ export class CampScene extends Phaser.Scene {
const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview); const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview);
const recommendedPresetId = this.recommendedSortiePresetId(); const recommendedPresetId = this.recommendedSortiePresetId();
const sortieFormationPresets = this.sortieFormationPresets(); const sortieFormationPresets = this.sortieFormationPresets();
const reportFormationReview = this.reportFormationReview();
const reportFormationSourcePresetIds = reportFormationReview
? this.reportFormationSourcePresetIds(reportFormationReview.snapshot)
: [];
return { return {
scene: this.scene.key, scene: this.scene.key,
activeTab: this.activeTab, activeTab: this.activeTab,
reportFormationEvaluation: reportFormationReview
? {
available: true,
open: this.reportFormationReviewVisible,
battleId: this.report?.battleId ?? null,
battleTitle: this.report?.battleTitle ?? null,
grade: reportFormationReview.grade,
score: reportFormationReview.score,
headline: reportFormationReview.headline,
strengths: [...reportFormationReview.strengths],
improvement: reportFormationReview.improvement,
objectiveAchieved: reportFormationReview.objectiveAchieved,
objectiveTotal: reportFormationReview.objectiveTotal,
survivorCount: reportFormationReview.survivorCount,
deployedCount: reportFormationReview.deployedCount,
recoveryNeededCount: reportFormationReview.recoveryNeededCount,
totalDamage: reportFormationReview.totalDamage,
totalDamageTaken: reportFormationReview.totalDamageTaken,
totalDefeats: reportFormationReview.totalDefeats,
totalSupport: reportFormationReview.totalSupport,
roleCoverage: reportFormationReview.roleCoverage,
activeBondCount: reportFormationReview.activeBondCount,
contributionTotals: { ...reportFormationReview.contributionTotals },
snapshot: {
selectedUnitIds: [...reportFormationReview.snapshot.selectedUnitIds],
formationAssignments: { ...reportFormationReview.snapshot.formationAssignments },
unitStats: reportFormationReview.snapshot.unitStats.map((stats) => ({ ...stats }))
},
sourcePresetIds: [...reportFormationSourcePresetIds],
overwriteConfirmId: this.reportFormationPresetOverwriteConfirmId ?? null,
feedback: this.reportFormationReviewFeedback,
toggleButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewToggleButton),
closeButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewView?.closeButton),
presetCards: sortiePresetDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
saved: Boolean(sortieFormationPresets[definition.id]),
matching: reportFormationSourcePresetIds.includes(definition.id),
actionButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewView?.presetButtons[definition.id])
}))
}
: {
available: false,
open: false,
battleId: this.report?.battleId ?? null,
battleTitle: this.report?.battleTitle ?? null,
sourcePresetIds: [],
overwriteConfirmId: null,
feedback: '',
toggleButtonBounds: null,
closeButtonBounds: null,
presetCards: []
},
sortieVisible: this.sortieObjects.length > 0, sortieVisible: this.sortieObjects.length > 0,
sortiePrepStep: this.sortiePrepStep, sortiePrepStep: this.sortiePrepStep,
stagedSortiePrep, stagedSortiePrep,
@@ -18462,9 +18931,18 @@ export class CampScene extends Phaser.Scene {
: null, : null,
report: this.report report: this.report
? { ? {
battleId: this.report.battleId,
battleTitle: this.report.battleTitle,
rewardGold: this.report.rewardGold, rewardGold: this.report.rewardGold,
objectives: this.report.objectives, objectives: this.report.objectives,
campaignRewards: this.report.campaignRewards, campaignRewards: this.report.campaignRewards,
sortiePerformance: this.report.sortiePerformance
? {
selectedUnitIds: [...this.report.sortiePerformance.selectedUnitIds],
formationAssignments: { ...this.report.sortiePerformance.formationAssignments },
unitStats: this.report.sortiePerformance.unitStats.map((stats) => ({ ...stats }))
}
: null,
completedCampDialogues: this.report.completedCampDialogues, completedCampDialogues: this.report.completedCampDialogues,
completedCampVisits: this.report.completedCampVisits, completedCampVisits: this.report.completedCampVisits,
bonds: this.report.bonds.map((bond) => ({ id: bond.id, level: bond.level, exp: bond.exp, battleExp: bond.battleExp })) bonds: this.report.bonds.map((bond) => ({ id: bond.id, level: bond.level, exp: bond.exp, battleExp: bond.battleExp }))