From f3d0127ab77bd41af33380ae59bb8f5b220df564 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 10 Jul 2026 23:38:14 +0900 Subject: [PATCH] feat: reopen formation reviews in camp --- scripts/verify-release-candidate.mjs | 388 ++++++++++++++++++ src/game/data/sortiePerformanceReview.ts | 195 +++++++++ src/game/scenes/BattleScene.ts | 167 +------- src/game/scenes/CampScene.ts | 482 ++++++++++++++++++++++- 4 files changed, 1081 insertions(+), 151 deletions(-) create mode 100644 src/game/data/sortiePerformanceReview.ts diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index e2d07a2..645a656 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -217,6 +217,135 @@ try { `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 firstCampDeploymentPreview = firstCampProbe.state?.sortieDeploymentPreview ?? []; 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); await page.mouse.click(658, 196); await page.waitForFunction((previousScroll) => { @@ -1363,6 +1530,146 @@ try { await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-formation-evaluation-updated.png`, fullPage: true }); 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, { battleId: 'sixty-sixth-battle-wuzhang-final', battleTitle: '오장원 최종전', @@ -1381,6 +1688,13 @@ try { 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.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( Array.isArray(finalCampState.sortieDeploymentPreview) && finalCampState.sortieDeploymentPreview.length === 0, `Expected final camp deployment preview to stay empty: ${JSON.stringify(finalCampState?.sortieDeploymentPreview)}` @@ -1723,6 +2037,80 @@ async function readBattleResultEvaluationControlPoint(page, control, presetId) { 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) { const probe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); diff --git a/src/game/data/sortiePerformanceReview.ts b/src/game/data/sortiePerformanceReview.ts new file mode 100644 index 0000000..e519b4a --- /dev/null +++ b/src/game/data/sortiePerformanceReview.ts @@ -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 = { + 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 = { + 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((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 => 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]) + ); +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 00be8f8..cd72245 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -49,6 +49,12 @@ import { sortieBondBonusForLevel, sortieRoleEffectSummaries } from '../data/sortieSynergy'; +import { + evaluateSortiePerformanceReview, + sortiePerformanceMatchesPreset, + sortiePerformanceRoleContributionLine, + type SortiePerformanceReview +} from '../data/sortiePerformanceReview'; import { campaignSortiePresetIds, campaignSaveSlotCount, @@ -1277,39 +1283,6 @@ type UnitBattleStats = { 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 = { closeButton: Phaser.GameObjects.Rectangle; presetButtons: Partial>; @@ -10777,125 +10750,21 @@ export class BattleScene extends Phaser.Scene { private resultFormationReview( outcome: BattleOutcome, snapshot: CampaignSortiePerformanceSnapshot - ): ResultFormationReview { - const unitsById = new Map(battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => [unit.id, unit])); - const deployedUnits = snapshot.selectedUnitIds - .map((unitId) => unitsById.get(unitId)) - .filter((unit): unit is UnitData => Boolean(unit)); - const objectives = this.resultObjectives(outcome); - const objectiveAchieved = 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 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 = { - 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 = { - 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, + ): SortiePerformanceReview { + return evaluateSortiePerformanceReview({ + outcome, + objectives: this.resultObjectives(outcome), + enemyCount: battleUnits.filter((unit) => unit.faction === 'enemy').length, + activeBondCount: this.activeSortieBonds().length, + endUnits: battleUnits + .filter((unit) => unit.faction === 'ally') + .map((unit) => ({ id: unit.id, hp: unit.hp, maxHp: unit.maxHp })), 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 => 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) { - const preset = getCampaignState().sortieFormationPresets[presetId]; - return Boolean( - preset && - JSON.stringify(preset.selectedUnitIds) === JSON.stringify(snapshot.selectedUnitIds) && - snapshot.selectedUnitIds.every((unitId) => preset.formationAssignments[unitId] === snapshot.formationAssignments[unitId]) - ); + return sortiePerformanceMatchesPreset(snapshot, getCampaignState().sortieFormationPresets[presetId]); } private resultFormationSourcePresetIds(snapshot: CampaignSortiePerformanceSnapshot) { @@ -10980,7 +10849,7 @@ export class BattleScene extends Phaser.Scene { this.renderResultFormationAnalysisPanel( '역할·공명 기여', [ - `${this.resultFormationRoleContributionLine(review)} · 역할 ${review.roleCoverage}/3`, + `${sortiePerformanceRoleContributionLine(review)} · 역할 ${review.roleCoverage}/3`, `공명 ${review.activeBondCount}개 · 연장 ${review.contributionTotals.extendedBondTriggers}회 · 파훼 ${review.contributionTotals.counterplays}` ], x + 624, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 4ced9fb..7925f9c 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -38,6 +38,12 @@ import { type SortieSynergyComparison, type SortieSynergySnapshot } from '../data/sortieSynergy'; +import { + evaluateSortiePerformanceReview, + sortiePerformanceMatchesPreset, + sortiePerformanceRoleContributionLine, + type SortiePerformanceReview +} from '../data/sortiePerformanceReview'; import { caoBreakRecruitBonds, caoBreakRecruitUnits, @@ -132,6 +138,7 @@ import { type CampaignReserveTrainingFocusId, type CampaignSortieItemAssignments, type CampaignSortieFormationPresets, + type CampaignSortiePerformanceSnapshot, type CampaignSortiePresetId, type FirstBattleReport } from '../state/campaignState'; @@ -435,6 +442,11 @@ type SortiePresetBrowserView = { cards: Partial>; }; +type ReportFormationReviewView = { + closeButton: Phaser.GameObjects.Rectangle; + presetButtons: Partial>; +}; + type SortieComparisonAction = { kind: 'confirm-swap' | 'undo-swap' | 'confirm-preset' | 'undo-preset'; label: string; @@ -10894,6 +10906,12 @@ export class CampScene extends Phaser.Scene { private saveSlotObjects: Phaser.GameObjects.GameObject[] = []; private saveSlotConfirmObjects: Phaser.GameObjects.GameObject[] = []; 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 visitedTabs = new Set(); private selectedSortieUnitIds: string[] = []; @@ -10944,6 +10962,12 @@ export class CampScene extends Phaser.Scene { this.saveSlotObjects = []; this.saveSlotConfirmObjects = []; this.pendingSaveSlot = undefined; + this.reportFormationReviewObjects = []; + this.reportFormationReviewVisible = false; + this.reportFormationReviewFeedback = ''; + this.reportFormationPresetOverwriteConfirmId = undefined; + this.reportFormationReviewToggleButton = undefined; + this.reportFormationReviewView = undefined; this.tabButtons = []; this.visitedTabs = new Set(['status']); this.campaign = getCampaignState(); @@ -12350,6 +12374,12 @@ export class CampScene extends Phaser.Scene { return; } + if (this.reportFormationReviewVisible) { + soundDirector.playSelect(); + this.hideReportFormationReview(); + return; + } + if (this.sortieObjects.length > 0) { soundDirector.playSelect(); this.hideSortiePrep(); @@ -12434,6 +12464,7 @@ export class CampScene extends Phaser.Scene { } private render() { + this.hideReportFormationReview(); this.hideCampSaveSlotPanel(); this.clearContent(); this.visitedTabs.add(this.activeTab); @@ -12471,6 +12502,7 @@ export class CampScene extends Phaser.Scene { private showSortiePrep() { const wasVisible = this.sortieObjects.length > 0; + this.hideReportFormationReview(); this.hideCampSaveSlotPanel(); this.hideSortiePrep(false); this.campaign = getCampaignState(); @@ -16605,6 +16637,7 @@ export class CampScene extends Phaser.Scene { return; } + this.reportFormationReviewToggleButton = undefined; const x = 42; const y = 590; 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( x + 18, 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') ) ); @@ -16623,10 +16656,80 @@ export class CampScene extends Phaser.Scene { this.add.text( x + 18, y + 62, - this.reportCampaignRewardLine(report), + this.compactText(this.reportCampaignRewardLine(report), 70), 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) { @@ -16648,6 +16751,314 @@ export class CampScene extends Phaser.Scene { 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(object: T) { + this.reportFormationReviewObjects.push(object); + return object; + } + private renderProgressPanel() { const x = 394; const y = 120; @@ -18220,6 +18631,7 @@ export class CampScene extends Phaser.Scene { private clearContent() { this.contentObjects.forEach((object) => object.destroy()); this.contentObjects = []; + this.reportFormationReviewToggleButton = undefined; } private sortieDeploymentPreviewDebug() { @@ -18256,9 +18668,66 @@ export class CampScene extends Phaser.Scene { const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview); const recommendedPresetId = this.recommendedSortiePresetId(); const sortieFormationPresets = this.sortieFormationPresets(); + const reportFormationReview = this.reportFormationReview(); + const reportFormationSourcePresetIds = reportFormationReview + ? this.reportFormationSourcePresetIds(reportFormationReview.snapshot) + : []; return { scene: this.scene.key, 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, sortiePrepStep: this.sortiePrepStep, stagedSortiePrep, @@ -18462,9 +18931,18 @@ export class CampScene extends Phaser.Scene { : null, report: this.report ? { + battleId: this.report.battleId, + battleTitle: this.report.battleTitle, rewardGold: this.report.rewardGold, objectives: this.report.objectives, 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, completedCampVisits: this.report.completedCampVisits, bonds: this.report.bonds.map((bond) => ({ id: bond.id, level: bond.level, exp: bond.exp, battleExp: bond.battleExp }))