From 481e870321ae8a502b4c687732f2a87785cf6fd6 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 10 Jul 2026 20:51:05 +0900 Subject: [PATCH] feat: confirm and undo sortie swaps --- scripts/verify-release-candidate.mjs | 325 +++++++++++++++++- src/game/scenes/CampScene.ts | 495 ++++++++++++++++++++++++--- 2 files changed, 771 insertions(+), 49 deletions(-) diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index e485b01..e4183b2 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -387,6 +387,33 @@ try { ); await advanceSortiePrepStep(page, 'formation'); + const midFormationSwapFixture = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp(); + const preservedUnitId = state?.selectedSortieUnitIds?.find((unitId) => unitId !== 'ma-dai'); + if (!scene || !preservedUnitId || typeof scene.persistSortieSelection !== 'function' || typeof scene.showSortiePrep !== 'function') { + return { ready: false, preservedUnitId: preservedUnitId ?? null }; + } + + scene.sortieFormationAssignments = { + ...scene.sortieFormationAssignments, + 'ma-dai': 'flank' + }; + scene.sortieItemAssignments = { + ...scene.sortieItemAssignments, + 'ma-dai': { ...(scene.sortieItemAssignments?.['ma-dai'] ?? {}), bean: 1 }, + [preservedUnitId]: { ...(scene.sortieItemAssignments?.[preservedUnitId] ?? {}), salve: 1 } + }; + scene.persistSortieSelection(); + scene.showSortiePrep(); + scene.persistSortieSelection(); + scene.showSortiePrep(); + return { ready: true, preservedUnitId }; + }); + assert( + midFormationSwapFixture.ready === true && midFormationSwapFixture.preservedUnitId, + `Expected a saved role and supply fixture for the swap regression: ${JSON.stringify(midFormationSwapFixture)}` + ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-formation.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp sortie formation'); @@ -401,6 +428,19 @@ try { const midFormationSelectedBefore = [...(midFormationPortraitState?.selectedSortieUnitIds ?? [])]; const midFormationCampaignSelectedBefore = [...(midFormationPortraitState?.campaign?.selectedSortieUnitIds ?? [])]; + const midFormationAssignmentsBefore = { ...(midFormationPortraitState?.sortieFormationAssignments ?? {}) }; + const midFormationItemAssignmentsBefore = structuredClone(midFormationPortraitState?.sortieItemAssignments ?? {}); + const midFormationSaveBefore = await readCampaignSave(page); + assert( + midFormationAssignmentsBefore['ma-dai'] === 'flank' && + midFormationItemAssignmentsBefore['ma-dai']?.bean === 1 && + midFormationItemAssignmentsBefore[midFormationSwapFixture.preservedUnitId]?.salve === 1, + `Expected the swap fixture to retain Ma Dai and another officer's assignments: ${JSON.stringify({ + formation: midFormationAssignmentsBefore, + items: midFormationItemAssignmentsBefore, + preservedUnitId: midFormationSwapFixture.preservedUnitId + })}` + ); await page.mouse.click(210, 447); await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieFocusedUnitId === 'ma-dai', undefined, { timeout: 30000 }); const swapFocusState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -430,7 +470,17 @@ try { }); const midFormationSwapPreviewState = midFormationSwapPreviewProbe.state; const swapPreview = midFormationSwapPreviewState?.sortieComparisonPreview; - const expectedProjectedSwapIds = midFormationSelectedBefore.map((unitId) => unitId === 'ma-dai' ? 'guan-yu' : unitId); + const projectedSwapIdSet = new Set( + midFormationSelectedBefore.map((unitId) => unitId === 'ma-dai' ? 'guan-yu' : unitId) + ); + const expectedProjectedSwapIds = [ + ...(midFormationSwapPreviewState?.sortieRoster ?? []) + .filter((unit) => unit.required && projectedSwapIdSet.has(unit.id)) + .map((unit) => unit.id), + ...(midFormationSwapPreviewState?.sortieRoster ?? []) + .filter((unit) => !unit.required && projectedSwapIdSet.has(unit.id)) + .map((unit) => unit.id) + ].slice(0, midFormationSelectedBefore.length); assert( midFormationPortraitState.sortiePortraitRoster.some( (unit) => unit.id === swapPreview?.incomingUnitId && unit.portraitReady && unit.portraitTextureKey?.startsWith('portrait-card-') @@ -504,6 +554,214 @@ try { `Expected pointerout to restore the visible Ma Dai focus preview: ${JSON.stringify(afterSwapPreviewProbe)}` ); + await page.mouse.move(530, 544); + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.sortieHoveredUnitId === 'guan-yu' && state?.sortieComparisonPreview?.source === 'hover-swap'; + }, undefined, { timeout: 30000 }); + await page.mouse.click(530, 544); + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.sortiePinnedSwapCandidateUnitId === 'guan-yu' && + state?.sortieComparisonPreview?.source === 'pinned-swap' && + state?.sortieComparisonAction?.kind === 'confirm-swap'; + }, undefined, { timeout: 30000 }); + const pinnedSwapProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp(); + const view = scene?.sortieComparisonPanelView; + return { + state, + action: { + buttonVisible: view?.actionButton?.visible ?? false, + labelVisible: view?.actionLabel?.visible ?? false, + label: view?.actionLabel?.text ?? null + } + }; + }); + const pinnedSwapState = pinnedSwapProbe.state; + const pinnedSwapPreview = pinnedSwapState?.sortieComparisonPreview; + const pinnedSwapSave = await readCampaignSave(page); + const previewIncomingRole = pinnedSwapState?.sortieRoster?.find((unit) => unit.id === 'guan-yu')?.formationRole; + assert( + pinnedSwapPreview?.source === 'pinned-swap' && + pinnedSwapPreview?.mode === 'swap' && + pinnedSwapPreview?.outgoingUnitId === 'ma-dai' && + pinnedSwapPreview?.incomingUnitId === 'guan-yu' && + JSON.stringify(pinnedSwapPreview?.projectedSelectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) && + previewIncomingRole === 'front' && + pinnedSwapState?.sortieComparisonAction?.label === '교체 확정' && + pinnedSwapState?.sortieComparisonAction?.outgoingUnitId === 'ma-dai' && + pinnedSwapState?.sortieComparisonAction?.incomingUnitId === 'guan-yu' && + pinnedSwapProbe.action.buttonVisible === true && + pinnedSwapProbe.action.labelVisible === true && + pinnedSwapProbe.action.label === '교체 확정', + `Expected clicking Guan Yu's card body to pin the canonical Ma Dai swap with a visible confirm action: ${JSON.stringify(pinnedSwapProbe)}` + ); + assert( + JSON.stringify(pinnedSwapState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore) && + JSON.stringify(pinnedSwapState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore) && + sameJsonValue(pinnedSwapState?.sortieFormationAssignments, midFormationAssignmentsBefore) && + sameJsonValue(pinnedSwapState?.sortieItemAssignments, midFormationItemAssignmentsBefore) && + JSON.stringify(pinnedSwapSave.current?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.current?.selectedSortieUnitIds) && + JSON.stringify(pinnedSwapSave.slot1?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.slot1?.selectedSortieUnitIds) && + sameJsonValue(pinnedSwapSave.current?.sortieFormationAssignments, midFormationSaveBefore.current?.sortieFormationAssignments) && + sameJsonValue(pinnedSwapSave.slot1?.sortieFormationAssignments, midFormationSaveBefore.slot1?.sortieFormationAssignments) && + sameJsonValue(pinnedSwapSave.current?.sortieItemAssignments, midFormationSaveBefore.current?.sortieItemAssignments) && + sameJsonValue(pinnedSwapSave.slot1?.sortieItemAssignments, midFormationSaveBefore.slot1?.sortieItemAssignments), + `Expected pinning the swap to preserve runtime, campaign, current-save, and slot-1 assignments: ${JSON.stringify({ + state: pinnedSwapState, + save: pinnedSwapSave + })}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-swap-pinned.png`, fullPage: true }); + + const confirmSwapActionPoint = await readSortieComparisonActionPoint(page); + assert( + confirmSwapActionPoint.label === '교체 확정', + `Expected the scaled Phaser action button to expose the confirm label: ${JSON.stringify(confirmSwapActionPoint)}` + ); + await page.mouse.click(confirmSwapActionPoint.x, confirmSwapActionPoint.y); + await page.waitForFunction((projectedIds) => { + const state = window.__HEROS_DEBUG__?.camp(); + return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(projectedIds) && + state?.sortieFormationAssignments?.['ma-dai'] === undefined && + state?.sortieFormationAssignments?.['guan-yu'] === 'front' && + state?.sortieItemAssignments?.['ma-dai'] === undefined && + state?.sortieItemAssignments?.['guan-yu'] === undefined && + state?.sortieSwapUndo?.available === true && + state?.sortieComparisonAction?.kind === 'undo-swap'; + }, expectedProjectedSwapIds, { timeout: 30000 }); + const confirmedSwapState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const confirmedSwapSave = await readCampaignSave(page); + const expectedConfirmedAssignments = { ...midFormationAssignmentsBefore }; + delete expectedConfirmedAssignments['ma-dai']; + expectedConfirmedAssignments['guan-yu'] = previewIncomingRole; + const expectedConfirmedItemAssignments = structuredClone(midFormationItemAssignmentsBefore); + delete expectedConfirmedItemAssignments['ma-dai']; + delete expectedConfirmedItemAssignments['guan-yu']; + assert( + JSON.stringify(confirmedSwapState?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds) && + JSON.stringify(confirmedSwapState?.campaign?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds) && + JSON.stringify(confirmedSwapSave.current?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds) && + JSON.stringify(confirmedSwapSave.slot1?.selectedSortieUnitIds) === JSON.stringify(expectedProjectedSwapIds), + `Expected swap confirmation to persist the canonical projected roster everywhere: ${JSON.stringify({ + expectedProjectedSwapIds, + runtime: confirmedSwapState?.selectedSortieUnitIds, + campaign: confirmedSwapState?.campaign?.selectedSortieUnitIds, + current: confirmedSwapSave.current?.selectedSortieUnitIds, + slot1: confirmedSwapSave.slot1?.selectedSortieUnitIds + })}` + ); + assert( + sameJsonValue(confirmedSwapState?.sortieFormationAssignments, expectedConfirmedAssignments) && + sameJsonValue(confirmedSwapState?.campaign?.sortieFormationAssignments, expectedConfirmedAssignments) && + sameJsonValue(confirmedSwapSave.current?.sortieFormationAssignments, expectedConfirmedAssignments) && + sameJsonValue(confirmedSwapSave.slot1?.sortieFormationAssignments, expectedConfirmedAssignments) && + sameJsonValue(confirmedSwapState?.sortieItemAssignments, expectedConfirmedItemAssignments) && + sameJsonValue(confirmedSwapState?.campaign?.sortieItemAssignments, expectedConfirmedItemAssignments) && + sameJsonValue(confirmedSwapSave.current?.sortieItemAssignments, expectedConfirmedItemAssignments) && + sameJsonValue(confirmedSwapSave.slot1?.sortieItemAssignments, expectedConfirmedItemAssignments), + `Expected confirmation to remove Ma Dai's role and supply, assign Guan Yu's preview role without transferring supply, and preserve every other assignment: ${JSON.stringify({ + expectedFormation: expectedConfirmedAssignments, + expectedItems: expectedConfirmedItemAssignments, + state: confirmedSwapState, + save: confirmedSwapSave + })}` + ); + assert( + confirmedSwapState?.sortieFocusedUnitId === 'guan-yu' && + confirmedSwapState?.sortiePinnedSwapCandidateUnitId === null && + confirmedSwapState?.sortieSwapUndo?.available === true && + confirmedSwapState?.sortieSwapUndo?.outgoingUnitId === 'ma-dai' && + confirmedSwapState?.sortieSwapUndo?.incomingUnitId === 'guan-yu' && + confirmedSwapState?.sortieComparisonAction?.kind === 'undo-swap' && + confirmedSwapState?.sortieComparisonAction?.label === '되돌리기' && + confirmedSwapState?.sortiePlanFeedback?.includes('장비·보급'), + `Expected confirmed swap feedback and one-use undo action: ${JSON.stringify(confirmedSwapState)}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-swap-confirmed.png`, fullPage: true }); + + const undoSwapActionPoint = await readSortieComparisonActionPoint(page); + assert( + undoSwapActionPoint.label === '되돌리기', + `Expected the scaled Phaser action button to switch to undo: ${JSON.stringify(undoSwapActionPoint)}` + ); + await page.mouse.click(undoSwapActionPoint.x, undoSwapActionPoint.y); + await page.waitForFunction((selectedBefore) => { + const state = window.__HEROS_DEBUG__?.camp(); + return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(selectedBefore) && + state?.sortieFocusedUnitId === 'ma-dai' && + state?.sortieComparisonAction === null && + !state?.sortieSwapUndo?.available; + }, midFormationSelectedBefore, { timeout: 30000 }); + const undoneSwapProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp(); + const view = scene?.sortieComparisonPanelView; + return { + state, + action: { + buttonVisible: view?.actionButton?.visible ?? false, + labelVisible: view?.actionLabel?.visible ?? false, + label: view?.actionLabel?.text ?? null + } + }; + }); + const undoneSwapState = undoneSwapProbe.state; + const undoneSwapSave = await readCampaignSave(page); + const undoRestoreChecks = { + runtimeSelection: JSON.stringify(undoneSwapState?.selectedSortieUnitIds) === JSON.stringify(midFormationSelectedBefore), + campaignSelection: JSON.stringify(undoneSwapState?.campaign?.selectedSortieUnitIds) === JSON.stringify(midFormationCampaignSelectedBefore), + currentSaveSelection: JSON.stringify(undoneSwapSave.current?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.current?.selectedSortieUnitIds), + slotSelection: JSON.stringify(undoneSwapSave.slot1?.selectedSortieUnitIds) === JSON.stringify(midFormationSaveBefore.slot1?.selectedSortieUnitIds), + runtimeFormation: sameJsonValue(undoneSwapState?.sortieFormationAssignments, midFormationAssignmentsBefore), + campaignFormation: sameJsonValue(undoneSwapState?.campaign?.sortieFormationAssignments, midFormationAssignmentsBefore), + currentSaveFormation: sameJsonValue(undoneSwapSave.current?.sortieFormationAssignments, midFormationSaveBefore.current?.sortieFormationAssignments), + slotFormation: sameJsonValue(undoneSwapSave.slot1?.sortieFormationAssignments, midFormationSaveBefore.slot1?.sortieFormationAssignments), + runtimeItems: sameJsonValue(undoneSwapState?.sortieItemAssignments, midFormationItemAssignmentsBefore), + campaignItems: sameJsonValue(undoneSwapState?.campaign?.sortieItemAssignments, midFormationItemAssignmentsBefore), + currentSaveItems: sameJsonValue(undoneSwapSave.current?.sortieItemAssignments, midFormationSaveBefore.current?.sortieItemAssignments), + slotItems: sameJsonValue(undoneSwapSave.slot1?.sortieItemAssignments, midFormationSaveBefore.slot1?.sortieItemAssignments) + }; + assert( + Object.values(undoRestoreChecks).every(Boolean), + `Expected one-time undo to restore the exact roster, role map, and supply map in runtime and both saves: ${JSON.stringify({ + checks: undoRestoreChecks, + expected: { + runtimeSelected: midFormationSelectedBefore, + campaignSelected: midFormationCampaignSelectedBefore, + currentSaveSelected: midFormationSaveBefore.current?.selectedSortieUnitIds, + slotSelected: midFormationSaveBefore.slot1?.selectedSortieUnitIds, + formation: midFormationAssignmentsBefore, + items: midFormationItemAssignmentsBefore + }, + actual: { + runtimeSelected: undoneSwapState?.selectedSortieUnitIds, + campaignSelected: undoneSwapState?.campaign?.selectedSortieUnitIds, + currentSaveSelected: undoneSwapSave.current?.selectedSortieUnitIds, + slotSelected: undoneSwapSave.slot1?.selectedSortieUnitIds, + runtimeFormation: undoneSwapState?.sortieFormationAssignments, + campaignFormation: undoneSwapState?.campaign?.sortieFormationAssignments, + currentSaveFormation: undoneSwapSave.current?.sortieFormationAssignments, + slotFormation: undoneSwapSave.slot1?.sortieFormationAssignments, + runtimeItems: undoneSwapState?.sortieItemAssignments, + campaignItems: undoneSwapState?.campaign?.sortieItemAssignments, + currentSaveItems: undoneSwapSave.current?.sortieItemAssignments, + slotItems: undoneSwapSave.slot1?.sortieItemAssignments + } + })}` + ); + assert( + undoneSwapState?.sortieFocusedUnitId === 'ma-dai' && + undoneSwapState?.sortiePinnedSwapCandidateUnitId === null && + undoneSwapState?.sortieComparisonAction === null && + !undoneSwapState?.sortieSwapUndo?.available && + undoneSwapProbe.action.buttonVisible === false && + undoneSwapProbe.action.labelVisible === false, + `Expected undo to restore Ma Dai focus and consume the panel action exactly once: ${JSON.stringify(undoneSwapProbe)}` + ); + const midFormationFirstPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); await page.mouse.click(658, 196); await page.waitForFunction((previousScroll) => { @@ -979,6 +1237,49 @@ async function readCampaignSave(page) { }); } +async function readSortieComparisonActionPoint(page) { + const probe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const actionButton = scene?.sortieComparisonPanelView?.actionButton; + const actionLabel = scene?.sortieComparisonPanelView?.actionLabel; + const canvas = document.querySelector('canvas'); + if (!scene || !actionButton?.visible || !actionLabel?.visible || !canvas) { + return { + ready: false, + buttonVisible: actionButton?.visible ?? false, + labelVisible: actionLabel?.visible ?? false, + label: actionLabel?.text ?? null + }; + } + + const bounds = actionButton.getBounds(); + const canvasBounds = canvas.getBoundingClientRect(); + const logicalWidth = scene.scale.width; + const logicalHeight = scene.scale.height; + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + label: actionLabel.text, + bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, + canvasBounds: { + x: canvasBounds.x, + y: canvasBounds.y, + width: canvasBounds.width, + height: canvasBounds.height + }, + logicalSize: { width: logicalWidth, height: logicalHeight }, + x: canvasBounds.left + centerX * canvasBounds.width / logicalWidth, + y: canvasBounds.top + centerY * canvasBounds.height / logicalHeight + }; + }); + assert( + probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), + `Expected a visible Phaser comparison action with a canvas-scaled click point: ${JSON.stringify(probe)}` + ); + return probe; +} + async function readBattleDoctrineProbe(page) { return page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); @@ -1146,6 +1447,28 @@ function assertSameMembers(values, expected, message) { assert(expected.every((value) => valueSet.has(value)), message); } +function sameJsonValue(value, expected) { + return stableJson(value) === stableJson(expected); +} + +function stableJson(value) { + return JSON.stringify(normalize(value)); + + function normalize(entry) { + if (Array.isArray(entry)) { + return entry.map(normalize); + } + if (entry && typeof entry === 'object') { + return Object.fromEntries( + Object.keys(entry) + .sort() + .map((key) => [key, normalize(entry[key])]) + ); + } + return entry; + } +} + function assertDeploymentMatchesPreview(positions, preview, message) { assert(Array.isArray(positions) && Array.isArray(preview), message); assert(positions.length === preview.length, message); diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index a46f4db..e463131 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -346,7 +346,7 @@ type SortieComparisonMetric = { }; type SortieFormationComparisonPreview = { - source: 'focus' | 'hover-add' | 'hover-swap' | 'hover-blocked'; + source: 'focus' | 'hover-add' | 'hover-swap' | 'pinned-swap' | 'hover-blocked'; mode: SortieUnitSynergyPreview['mode'] | 'swap'; unitId: string; incomingUnitId: string | null; @@ -375,6 +375,42 @@ type SortieComparisonPanelView = { }[]; impact: Phaser.GameObjects.Text; detail: Phaser.GameObjects.Text; + actionButton: Phaser.GameObjects.Rectangle; + actionLabel: Phaser.GameObjects.Text; +}; + +type SortieConfigurationSnapshot = { + selectedUnitIds: string[]; + formationAssignments: SortieFormationAssignments; + itemAssignments: CampaignSortieItemAssignments; + focusedUnitId: string; + planFeedback: string; + rosterScroll: number; +}; + +type SortieSwapProjection = { + outgoingUnit: UnitData; + incomingUnit: UnitData; + selectedUnitIds: string[]; + formationAssignments: SortieFormationAssignments; + itemAssignments: CampaignSortieItemAssignments; + incomingRole: SortieFormationRole; +}; + +type SortieSwapUndoState = { + outgoingUnitId: string; + outgoingUnitName: string; + incomingUnitId: string; + incomingUnitName: string; + before: SortieConfigurationSnapshot; + applied: SortieConfigurationSnapshot; +}; + +type SortieComparisonAction = { + kind: 'confirm-swap' | 'undo-swap'; + label: string; + outgoingUnitId: string; + incomingUnitId: string; }; type CampaignTimelineChapter = { @@ -10824,6 +10860,8 @@ export class CampScene extends Phaser.Scene { private sortieItemAssignments: CampaignSortieItemAssignments = {}; private sortieFocusedUnitId = 'liu-bei'; private sortieHoveredUnitId?: string; + private sortiePinnedSwapCandidateUnitId?: string; + private sortieSwapUndoState?: SortieSwapUndoState; private sortieComparisonPanelView?: SortieComparisonPanelView; private sortieRosterScroll = 0; private sortiePlanFeedback = ''; @@ -10846,6 +10884,8 @@ export class CampScene extends Phaser.Scene { this.dialogueObjects = []; this.sortieObjects = []; this.sortieComparisonPanelView = undefined; + this.sortiePinnedSwapCandidateUnitId = undefined; + this.sortieSwapUndoState = undefined; this.saveSlotObjects = []; this.saveSlotConfirmObjects = []; this.pendingSaveSlot = undefined; @@ -12194,6 +12234,8 @@ export class CampScene extends Phaser.Scene { private saveCampToSlot(slot: number) { soundDirector.playSelect(); const returnToSortiePrep = this.sortieObjects.length > 0; + this.sortieSwapUndoState = undefined; + this.sortiePinnedSwapCandidateUnitId = undefined; this.hideCampSaveSlotPanel(); this.campaign = saveCampaignState(getCampaignState(), slot); if (returnToSortiePrep) { @@ -12373,7 +12415,7 @@ export class CampScene extends Phaser.Scene { private showSortiePrep() { const wasVisible = this.sortieObjects.length > 0; this.hideCampSaveSlotPanel(); - this.hideSortiePrep(); + this.hideSortiePrep(false); this.campaign = getCampaignState(); this.report = this.campaign.firstBattleReport ?? this.report; this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds); @@ -12520,6 +12562,8 @@ export class CampScene extends Phaser.Scene { return; } this.sortiePrepStep = step; + this.sortiePinnedSwapCandidateUnitId = undefined; + this.sortieHoveredUnitId = undefined; this.sortieRosterScroll = 0; soundDirector.playSelect(); this.showSortiePrep(); @@ -12765,15 +12809,26 @@ export class CampScene extends Phaser.Scene { const cardY = cardTop + rowIndex * (cardHeight + rowGap); const selected = this.isSortieSelected(unit.id); const focused = this.sortieFocusedUnitId === unit.id; + const pinnedSwapCandidate = this.sortiePinnedSwapCandidateUnitId === unit.id; const required = this.isRequiredSortieUnit(unit.id); const recommendation = this.sortieRecommendation(unit.id); const availability = this.sortieUnitAvailability(unit); const role = this.sortieFormationRole(unit); const preview = this.sortieUnitSynergyPreview(unit); - const fill = !availability.available ? 0x11151a : selected ? 0x172a22 : recommendation ? 0x241f17 : 0x151b24; - const accentColor = focused ? palette.gold : selected ? palette.green : recommendation ? palette.gold : 0x53606c; - const restingStrokeWidth = focused ? 2 : selected ? 1.5 : 1; - const restingStrokeAlpha = focused ? 0.96 : selected ? 0.68 : recommendation ? 0.52 : 0.34; + const canPinSwap = availability.available && !selected && selectedCount >= maxUnits && + this.isSortieSelected(this.sortieFocusedUnitId) && !this.isRequiredSortieUnit(this.sortieFocusedUnitId); + const fill = !availability.available + ? 0x11151a + : pinnedSwapCandidate + ? 0x1d2731 + : selected + ? 0x172a22 + : recommendation + ? 0x241f17 + : 0x151b24; + const accentColor = focused || pinnedSwapCandidate ? palette.gold : selected ? palette.green : recommendation ? palette.gold : 0x53606c; + const restingStrokeWidth = focused || pinnedSwapCandidate ? 2 : selected ? 1.5 : 1; + const restingStrokeAlpha = focused || pinnedSwapCandidate ? 0.96 : selected ? 0.68 : recommendation ? 0.52 : 0.34; const card = this.trackSortie(this.add.rectangle(cardX, cardY, cardWidth, cardHeight, fill, !availability.available ? 0.62 : 0.96)); card.setOrigin(0); card.setDepth(depth + 1); @@ -12781,6 +12836,16 @@ export class CampScene extends Phaser.Scene { card.setInteractive({ useHandCursor: true }); card.on('wheel', handleRosterWheel); card.on('pointerdown', () => { + const comparisonPreview = this.sortieFormationComparisonPreview(); + if ( + comparisonPreview?.mode === 'swap' && + comparisonPreview.incomingUnitId === unit.id && + (comparisonPreview.source === 'hover-swap' || comparisonPreview.source === 'pinned-swap') + ) { + this.pinSortieSwapCandidate(unit.id); + return; + } + this.sortiePinnedSwapCandidateUnitId = undefined; this.sortieFocusedUnitId = unit.id; soundDirector.playSelect(); if (!availability.available) { @@ -12789,7 +12854,7 @@ export class CampScene extends Phaser.Scene { this.showSortiePrep(); }); - const accent = this.trackSortie(this.add.rectangle(cardX + 3, cardY + 3, 4, cardHeight - 6, accentColor, focused || selected ? 0.94 : recommendation ? 0.72 : 0.34)); + const accent = this.trackSortie(this.add.rectangle(cardX + 3, cardY + 3, 4, cardHeight - 6, accentColor, focused || selected || pinnedSwapCandidate ? 0.94 : recommendation ? 0.72 : 0.34)); accent.setOrigin(0); accent.setDepth(depth + 2); const portraitView = this.renderFirstSortiePortrait( @@ -12802,6 +12867,9 @@ export class CampScene extends Phaser.Scene { false, accentColor ); + if (pinnedSwapCandidate && portraitView.image) { + portraitView.image.setDisplaySize(60, 60); + } card.on('pointerover', () => { this.sortieHoveredUnitId = unit.id; this.refreshCampaignSortieComparisonPanel(); @@ -12818,14 +12886,22 @@ export class CampScene extends Phaser.Scene { this.sortieHoveredUnitId = undefined; } this.refreshCampaignSortieComparisonPanel(); + if (this.sortiePinnedSwapCandidateUnitId === unit.id) { + card.setFillStyle(0x1d2731, 0.98); + card.setStrokeStyle(2, palette.gold, 0.98); + accent.setFillStyle(palette.gold, 0.98); + portraitView.frame.setStrokeStyle(2, palette.gold, 0.98); + portraitView.image?.setDisplaySize(60, 60); + return; + } card.setFillStyle(fill, !availability.available ? 0.62 : 0.96); card.setStrokeStyle(restingStrokeWidth, availability.available ? accentColor : 0x53606c, restingStrokeAlpha); accent.setFillStyle(accentColor, focused || selected ? 0.94 : recommendation ? 0.72 : 0.34); portraitView.frame.setStrokeStyle(2, accentColor, focused ? 0.92 : selected ? 0.78 : recommendation ? 0.62 : 0.42); portraitView.image?.setDisplaySize(56, 56); }); - const statusLabel = required ? '필수' : selected ? '출전' : recommendation ? '추천' : '대기'; - const statusColor = required || recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf'; + const statusLabel = required ? '필수' : selected ? '출전' : pinnedSwapCandidate ? '교체안' : recommendation ? '추천' : '대기'; + const statusColor = required || recommendation || pinnedSwapCandidate ? '#ffdf7b' : selected ? '#a8ffd0' : '#9fb0bf'; const status = this.trackSortie(this.add.text(cardX + cardWidth - 10, cardY + 8, statusLabel, this.textStyle(9, statusColor, true))); status.setOrigin(1, 0); status.setDepth(depth + 2); @@ -12849,14 +12925,18 @@ export class CampScene extends Phaser.Scene { const canToggle = availability.available && !(selected && required); this.renderFirstSortieInlineButton( - required ? '필수 출전' : selected ? '출전 해제' : '출전 등록', + required ? '필수 출전' : selected ? '출전 해제' : pinnedSwapCandidate ? '후보 고정' : canPinSwap ? '교체 비교' : '출전 등록', cardX + cardWidth - 80, cardY + cardHeight - 25, 70, 20, canToggle, - selected, + selected || pinnedSwapCandidate, () => { + if (canPinSwap || pinnedSwapCandidate) { + this.pinSortieSwapCandidate(unit.id); + return; + } this.sortieFocusedUnitId = unit.id; this.toggleSortieUnit(unit.id); }, @@ -12876,6 +12956,31 @@ export class CampScene extends Phaser.Scene { summary.setDepth(depth + 1); const headline = this.trackSortie(this.add.text(x + 18, y + 31, '', this.textStyle(11, '#c8d2dd', true))); headline.setDepth(depth + 1); + const actionButton = this.trackSortie(this.add.rectangle(x + width - 108, y + 27, 90, 20, 0x263a2d, 0.98)); + actionButton.setOrigin(0); + actionButton.setDepth(depth + 2); + actionButton.setStrokeStyle(1, palette.green, 0.78); + actionButton.setVisible(false); + actionButton.disableInteractive(); + actionButton.on('pointerover', () => { + if (actionButton.visible) { + actionButton.setFillStyle(0x365044, 1).setStrokeStyle(1, palette.gold, 0.96); + } + }); + actionButton.on('pointerout', () => { + const action = this.sortieComparisonAction(); + if (actionButton.visible && action) { + const undo = action.kind === 'undo-swap'; + actionButton + .setFillStyle(undo ? 0x243442 : 0x263a2d, 0.98) + .setStrokeStyle(1, undo ? palette.blue : palette.green, 0.78); + } + }); + actionButton.on('pointerdown', () => this.handleSortieComparisonAction()); + const actionLabel = this.trackSortie(this.add.text(x + width - 63, y + 37, '', this.textStyle(10, '#f2e3bf', true))); + actionLabel.setOrigin(0.5); + actionLabel.setDepth(depth + 3); + actionLabel.setVisible(false); const metricGap = 6; const metricWidth = Math.floor((width - 36 - metricGap * 3) / 4); @@ -12905,7 +13010,9 @@ export class CampScene extends Phaser.Scene { headline, metrics, impact, - detail + detail, + actionButton, + actionLabel }; this.refreshCampaignSortieComparisonPanel(); } @@ -12917,11 +13024,14 @@ export class CampScene extends Phaser.Scene { } const preview = this.sortieFormationComparisonPreview(); + const action = this.sortieComparisonAction(preview); + this.refreshSortieComparisonActionButton(view, action); + const isUndoAction = action?.kind === 'undo-swap'; const current = preview?.current ?? this.sortieSynergySnapshot(); - const projected = preview?.projected ?? current; - const showMetricTransition = Boolean(preview?.projected); - const comparison = preview?.comparison; - const isHoverPreview = preview?.source === 'hover-add' || preview?.source === 'hover-swap'; + const projected = isUndoAction ? current : preview?.projected ?? current; + const showMetricTransition = !isUndoAction && Boolean(preview?.projected); + const comparison = isUndoAction ? undefined : preview?.comparison; + const isHoverPreview = preview?.source === 'hover-add' || preview?.source === 'hover-swap' || preview?.source === 'pinned-swap'; const lostRole = Boolean(comparison?.lostRoles.length); const completeFormation = projected.coveredRoleCount === coreSortieSynergyRoles.length; view.background.setStrokeStyle( @@ -12943,7 +13053,11 @@ export class CampScene extends Phaser.Scene { return; } - const title = preview.source === 'hover-swap' + const title = isUndoAction && this.sortieSwapUndoState + ? `교체 완료 · ${this.sortieSwapUndoState.outgoingUnitName} → ${this.sortieSwapUndoState.incomingUnitName}` + : preview.source === 'pinned-swap' + ? `교체안 고정 · ${preview.outgoingUnitName} → ${preview.incomingUnitName}` + : preview.source === 'hover-swap' ? `가상 교체 · ${preview.outgoingUnitName} → ${preview.incomingUnitName}` : preview.source === 'hover-add' ? `가상 합류 · ${preview.incomingUnitName}` @@ -12955,7 +13069,7 @@ export class CampScene extends Phaser.Scene { 38 ) ); - const headlineColor = preview.source === 'hover-swap' || preview.mode === 'add' + const headlineColor = isUndoAction || preview.source === 'hover-swap' || preview.source === 'pinned-swap' || preview.mode === 'add' ? '#a8ffd0' : preview.mode === 'remove' ? '#ff9d7d' @@ -12964,7 +13078,10 @@ export class CampScene extends Phaser.Scene { : preview.mode === 'blocked' ? '#87919c' : '#d4dce6'; - view.headline.setText(this.compactText(preview.headline, 64)).setColor(headlineColor); + const headline = isUndoAction && this.sortieSwapUndoState + ? `${this.sortieSwapUndoState.incomingUnitName} 편성 완료 · 장비·보급 재점검` + : preview.headline; + view.headline.setText(this.compactText(headline, action ? 44 : 64)).setColor(headlineColor); view.metrics.forEach((metricView, index) => { const metric = preview.metrics[index]; @@ -12986,6 +13103,16 @@ export class CampScene extends Phaser.Scene { .setColor(color); }); + if (isUndoAction && this.sortieSwapUndoState) { + view.impact + .setText(`현재 역할 ${current.coveredRoleCount}/3 · 공명 ${current.activeBondCount} · 지형 ${current.terrainGrade}`) + .setColor('#a8ffd0'); + view.detail + .setText('장비·보급은 자동 이전하지 않았습니다. 직전 교체는 한 번 되돌릴 수 있습니다.') + .setColor('#ffdf7b'); + return; + } + if (!comparison) { view.impact .setText(this.compactText(preview.detail, 68)) @@ -13032,6 +13159,65 @@ export class CampScene extends Phaser.Scene { .setColor(isHoverPreview ? '#ffdf7b' : '#9fb0bf'); } + private refreshSortieComparisonActionButton(view: SortieComparisonPanelView, action?: SortieComparisonAction) { + const visible = Boolean(action); + view.actionButton.setVisible(visible); + view.actionLabel.setVisible(visible); + if (!action) { + view.actionButton.disableInteractive(); + view.actionLabel.setText(''); + return; + } + + const undo = action.kind === 'undo-swap'; + view.actionButton + .setInteractive({ useHandCursor: true }) + .setFillStyle(undo ? 0x243442 : 0x263a2d, 0.98) + .setStrokeStyle(1, undo ? palette.blue : palette.green, 0.78); + view.actionLabel.setText(action.label).setColor(undo ? '#cfe8ff' : '#e6ffd5'); + } + + private sortieComparisonAction( + preview = this.sortieFormationComparisonPreview() + ): SortieComparisonAction | undefined { + if ( + preview?.source === 'pinned-swap' && + preview.mode === 'swap' && + preview.outgoingUnitId && + preview.incomingUnitId && + this.sortieSwapProjection(preview.outgoingUnitId, preview.incomingUnitId) + ) { + return { + kind: 'confirm-swap', + label: '교체 확정', + outgoingUnitId: preview.outgoingUnitId, + incomingUnitId: preview.incomingUnitId + }; + } + + const undo = this.sortieSwapUndoState; + if (undo && this.sortiePersistedConfigurationMatches(undo.applied)) { + return { + kind: 'undo-swap', + label: '되돌리기', + outgoingUnitId: undo.outgoingUnitId, + incomingUnitId: undo.incomingUnitId + }; + } + return undefined; + } + + private handleSortieComparisonAction() { + const action = this.sortieComparisonAction(); + if (action?.kind === 'confirm-swap') { + this.confirmPinnedSortieSwap(); + return; + } + if (action?.kind === 'undo-swap') { + this.undoLastSortieSwap(); + } + } + private renderFirstSortieRoleDiagram(x: number, y: number, width: number, height: number, depth: number) { const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94)); bg.setOrigin(0); @@ -14142,44 +14328,49 @@ export class CampScene extends Phaser.Scene { const hovered = this.sortieHoveredUnitId ? this.sortieRosterUnits().find((unit) => unit.id === this.sortieHoveredUnitId) : undefined; + const pinned = this.sortiePinnedSwapCandidateUnitId + ? this.sortieRosterUnits().find((unit) => unit.id === this.sortiePinnedSwapCandidateUnitId) + : undefined; + const candidate = hovered ?? pinned; + const pinnedCandidate = candidate?.id === pinned?.id; - if (hovered && hovered.id !== focused?.id && !selectedIds.has(hovered.id)) { - const availability = this.sortieUnitAvailability(hovered, scenario); + if (candidate && candidate.id !== focused?.id && !selectedIds.has(candidate.id)) { + const availability = this.sortieUnitAvailability(candidate, scenario); if (!availability.available) { return { source: 'hover-blocked', mode: 'blocked', - unitId: hovered.id, - incomingUnitId: hovered.id, - incomingUnitName: hovered.name, + unitId: candidate.id, + incomingUnitId: candidate.id, + incomingUnitName: candidate.name, outgoingUnitId: null, outgoingUnitName: null, selectedUnitIds, projectedSelectedUnitIds: selectedUnitIds, - headline: `${hovered.name} 편성 불가 · ${availability.reason}`, + headline: `${candidate.name} 편성 불가 · ${availability.reason}`, detail: '현재 출전 명단은 유지됩니다.', - metrics: this.sortieComparisonMetrics(hovered, hovered, scenario), + metrics: this.sortieComparisonMetrics(candidate, candidate, scenario), current }; } if (selectedUnitIds.length < this.sortieMaxUnits(scenario)) { - const projectedSelectedUnitIds = [...selectedUnitIds, hovered.id]; + const projectedSelectedUnitIds = [...selectedUnitIds, candidate.id]; const projected = this.sortieSynergySnapshot(projectedSelectedUnitIds, scenario); const comparison = compareSortieSynergy(current, projected); return { source: 'hover-add', mode: 'add', - unitId: hovered.id, - incomingUnitId: hovered.id, - incomingUnitName: hovered.name, + unitId: candidate.id, + incomingUnitId: candidate.id, + incomingUnitName: candidate.name, outgoingUnitId: null, outgoingUnitName: null, selectedUnitIds, projectedSelectedUnitIds, - headline: `IN ${hovered.name} · ${this.sortieFormationRoleLabel(this.sortieFormationRole(hovered, scenario))} 합류`, + headline: `IN ${candidate.name} · ${this.sortieFormationRoleLabel(this.sortieFormationRole(candidate, scenario))} 합류`, detail: `빈 출전 슬롯에 합류했을 때의 가상 결과입니다.`, - metrics: this.sortieComparisonMetrics(undefined, hovered, scenario), + metrics: this.sortieComparisonMetrics(undefined, candidate, scenario), current, projected, comparison @@ -14193,38 +14384,44 @@ export class CampScene extends Phaser.Scene { return { source: 'hover-blocked', mode: 'waiting', - unitId: hovered.id, - incomingUnitId: hovered.id, - incomingUnitName: hovered.name, + unitId: candidate.id, + incomingUnitId: candidate.id, + incomingUnitName: candidate.name, outgoingUnitId: null, outgoingUnitName: null, selectedUnitIds, projectedSelectedUnitIds: selectedUnitIds, headline: `교체 기준 필요 · 출전 중인 비필수 무장을 먼저 클릭하세요.`, - detail: `${hovered.name} 후보는 아직 실제 편성에 반영되지 않았습니다.`, - metrics: this.sortieComparisonMetrics(hovered, hovered, scenario), + detail: `${candidate.name} 후보는 아직 실제 편성에 반영되지 않았습니다.`, + metrics: this.sortieComparisonMetrics(candidate, candidate, scenario), current }; } - const projectedSelectedUnitIds = selectedUnitIds.map((unitId) => unitId === outgoing.id ? hovered.id : unitId); + const projection = this.sortieSwapProjection(outgoing.id, candidate.id, scenario); + if (!projection) { + return undefined; + } + const projectedSelectedUnitIds = projection.selectedUnitIds; const projected = this.sortieSynergySnapshot(projectedSelectedUnitIds, scenario); const comparison = compareSortieSynergy(current, projected); const outgoingRole = this.sortieFormationRoleLabel(this.sortieFormationRole(outgoing, scenario)); - const incomingRole = this.sortieFormationRoleLabel(this.sortieFormationRole(hovered, scenario)); + const incomingRole = this.sortieFormationRoleLabel(projection.incomingRole); return { - source: 'hover-swap', + source: pinnedCandidate ? 'pinned-swap' : 'hover-swap', mode: 'swap', - unitId: hovered.id, - incomingUnitId: hovered.id, - incomingUnitName: hovered.name, + unitId: candidate.id, + incomingUnitId: candidate.id, + incomingUnitName: candidate.name, outgoingUnitId: outgoing.id, outgoingUnitName: outgoing.name, selectedUnitIds, projectedSelectedUnitIds, - headline: `OUT ${outgoing.name} ${outgoingRole} · IN ${hovered.name} ${incomingRole}`, - detail: `${outgoing.name} 대신 ${hovered.name}을 투입했을 때의 가상 결과입니다.`, - metrics: this.sortieComparisonMetrics(outgoing, hovered, scenario), + headline: `OUT ${outgoing.name} ${outgoingRole} · IN ${candidate.name} ${incomingRole}`, + detail: pinnedCandidate + ? `${outgoing.name} 대신 ${candidate.name}을 투입하는 교체안을 고정했습니다.` + : `${outgoing.name} 대신 ${candidate.name}을 투입했을 때의 가상 결과입니다.`, + metrics: this.sortieComparisonMetrics(outgoing, candidate, scenario), current, projected, comparison @@ -14261,6 +14458,192 @@ export class CampScene extends Phaser.Scene { }; } + private sortieSwapProjection( + outgoingUnitId: string, + incomingUnitId: string, + scenario = this.nextSortieScenario() + ): SortieSwapProjection | undefined { + if (outgoingUnitId === incomingUnitId) { + return undefined; + } + + const roster = this.sortieRosterUnits(); + const outgoingUnit = roster.find((unit) => unit.id === outgoingUnitId); + const incomingUnit = roster.find((unit) => unit.id === incomingUnitId); + const selectedIds = new Set(this.selectedSortieUnitIds); + if ( + !outgoingUnit || + !incomingUnit || + !selectedIds.has(outgoingUnitId) || + selectedIds.has(incomingUnitId) || + this.isRequiredSortieUnit(outgoingUnitId, scenario) || + this.selectedSortieUnitIds.length !== this.sortieMaxUnits(scenario) || + !this.sortieUnitAvailability(incomingUnit, scenario).available + ) { + return undefined; + } + + const replacedIds = this.selectedSortieUnitIds.map((unitId) => unitId === outgoingUnitId ? incomingUnitId : unitId); + const selectedUnitIds = this.normalizedSortieUnitIds(replacedIds); + const requiredIds = this.requiredSortieUnitIdsFor(scenario); + if ( + selectedUnitIds.length !== this.selectedSortieUnitIds.length || + new Set(selectedUnitIds).size !== selectedUnitIds.length || + selectedUnitIds.includes(outgoingUnitId) || + !selectedUnitIds.includes(incomingUnitId) || + [...requiredIds].some((unitId) => roster.some((unit) => unit.id === unitId) && !selectedUnitIds.includes(unitId)) + ) { + return undefined; + } + + const incomingRole = this.sortieFormationRole(incomingUnit, scenario); + const formationAssignments = { ...this.sortieFormationAssignments }; + delete formationAssignments[outgoingUnitId]; + delete formationAssignments[incomingUnitId]; + formationAssignments[incomingUnitId] = incomingRole; + const itemAssignments = this.cloneSortieItemAssignments(this.sortieItemAssignments); + delete itemAssignments[outgoingUnitId]; + delete itemAssignments[incomingUnitId]; + return { + outgoingUnit, + incomingUnit, + selectedUnitIds, + formationAssignments, + itemAssignments, + incomingRole + }; + } + + private pinSortieSwapCandidate(incomingUnitId: string) { + const projection = this.sortieSwapProjection(this.sortieFocusedUnitId, incomingUnitId); + if (!projection) { + this.sortiePinnedSwapCandidateUnitId = undefined; + this.refreshCampaignSortieComparisonPanel(); + this.showCampNotice('교체 기준과 후보 상태를 다시 확인하십시오.'); + return; + } + + this.sortiePinnedSwapCandidateUnitId = incomingUnitId; + this.sortieHoveredUnitId = undefined; + soundDirector.playSelect(); + this.showSortiePrep(); + } + + private captureSortieConfiguration(): SortieConfigurationSnapshot { + return { + selectedUnitIds: [...this.selectedSortieUnitIds], + formationAssignments: { ...this.sortieFormationAssignments }, + itemAssignments: this.cloneSortieItemAssignments(this.sortieItemAssignments), + focusedUnitId: this.sortieFocusedUnitId, + planFeedback: this.sortiePlanFeedback, + rosterScroll: this.sortieRosterScroll + }; + } + + private sortiePersistedConfigurationKey( + selectedUnitIds: readonly string[], + formationAssignments: SortieFormationAssignments, + itemAssignments: CampaignSortieItemAssignments + ) { + return JSON.stringify({ + selectedUnitIds, + formationAssignments: Object.entries(formationAssignments).sort(([left], [right]) => left.localeCompare(right)), + itemAssignments: Object.entries(itemAssignments) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([unitId, stocks]) => [unitId, Object.entries(stocks).sort(([left], [right]) => left.localeCompare(right))]) + }); + } + + private sortiePersistedConfigurationMatches(snapshot: SortieConfigurationSnapshot) { + const expected = this.sortiePersistedConfigurationKey( + snapshot.selectedUnitIds, + snapshot.formationAssignments, + snapshot.itemAssignments + ); + const runtime = this.sortiePersistedConfigurationKey( + this.selectedSortieUnitIds, + this.sortieFormationAssignments, + this.sortieItemAssignments + ); + const campaign = this.campaign ?? getCampaignState(); + const saved = this.sortiePersistedConfigurationKey( + campaign.selectedSortieUnitIds, + campaign.sortieFormationAssignments, + campaign.sortieItemAssignments + ); + return runtime === expected && saved === expected; + } + + private confirmPinnedSortieSwap() { + const preview = this.sortieFormationComparisonPreview(); + if ( + preview?.source !== 'pinned-swap' || + preview.mode !== 'swap' || + !preview.outgoingUnitId || + !preview.incomingUnitId + ) { + this.sortiePinnedSwapCandidateUnitId = undefined; + this.refreshCampaignSortieComparisonPanel(); + this.showCampNotice('고정된 교체안이 없습니다. 후보 초상 카드를 먼저 클릭하십시오.'); + return; + } + + const projection = this.sortieSwapProjection(preview.outgoingUnitId, preview.incomingUnitId); + if (!projection || JSON.stringify(projection.selectedUnitIds) !== JSON.stringify(preview.projectedSelectedUnitIds)) { + this.sortiePinnedSwapCandidateUnitId = undefined; + this.refreshCampaignSortieComparisonPanel(); + this.showCampNotice('교체 조건이 달라졌습니다. 편성 상태를 다시 확인하십시오.'); + return; + } + + const before = this.captureSortieConfiguration(); + this.sortieSwapUndoState = undefined; + this.selectedSortieUnitIds = [...projection.selectedUnitIds]; + this.sortieFormationAssignments = { ...projection.formationAssignments }; + this.sortieItemAssignments = this.cloneSortieItemAssignments(projection.itemAssignments); + this.sortieFocusedUnitId = projection.incomingUnit.id; + this.sortieHoveredUnitId = undefined; + this.sortiePinnedSwapCandidateUnitId = undefined; + this.sortieRosterScroll = 0; + this.sortiePlanFeedback = `${projection.outgoingUnit.name} → ${projection.incomingUnit.name} 교체 완료 · 장비·보급 재점검`; + this.persistSortieSelection(); + this.sortieSwapUndoState = { + outgoingUnitId: projection.outgoingUnit.id, + outgoingUnitName: projection.outgoingUnit.name, + incomingUnitId: projection.incomingUnit.id, + incomingUnitName: projection.incomingUnit.name, + before, + applied: this.captureSortieConfiguration() + }; + soundDirector.playSelect(); + this.showSortiePrep(); + this.showCampNotice(`${projection.incomingUnit.name} 교체 완료 · 장비·보급은 자동 이전하지 않았습니다.`); + } + + private undoLastSortieSwap() { + const undo = this.sortieSwapUndoState; + if (!undo || !this.sortiePersistedConfigurationMatches(undo.applied)) { + this.sortieSwapUndoState = undefined; + this.refreshCampaignSortieComparisonPanel(); + this.showCampNotice('이후 편성이 변경되어 직전 교체를 되돌릴 수 없습니다.'); + return; + } + + this.sortieSwapUndoState = undefined; + this.selectedSortieUnitIds = [...undo.before.selectedUnitIds]; + this.sortieFormationAssignments = { ...undo.before.formationAssignments }; + this.sortieItemAssignments = this.cloneSortieItemAssignments(undo.before.itemAssignments); + this.sortieFocusedUnitId = undo.before.focusedUnitId; + this.sortiePlanFeedback = undo.before.planFeedback; + this.sortieRosterScroll = undo.before.rosterScroll; + this.sortieHoveredUnitId = undefined; + this.sortiePinnedSwapCandidateUnitId = undefined; + this.persistSortieSelection(); + soundDirector.playSelect(); + this.showSortiePrep(); + this.showCampNotice(`${undo.outgoingUnitName} → ${undo.incomingUnitName} 교체를 되돌렸습니다.`); + } + private sortieComparisonMetrics( currentUnit: UnitData | undefined, projectedUnit: UnitData | undefined, @@ -15393,10 +15776,13 @@ export class CampScene extends Phaser.Scene { return flow.campaignStep === 'ending-complete' && !flow.nextBattleId; } - private hideSortiePrep() { + private hideSortiePrep(clearPinnedSwap = true) { this.sortieObjects.forEach((object) => object.destroy()); this.sortieObjects = []; this.sortieHoveredUnitId = undefined; + if (clearPinnedSwap) { + this.sortiePinnedSwapCandidateUnitId = undefined; + } this.sortieComparisonPanelView = undefined; } @@ -16915,6 +17301,8 @@ export class CampScene extends Phaser.Scene { } private persistSortieSelection() { + this.sortieSwapUndoState = undefined; + this.sortiePinnedSwapCandidateUnitId = undefined; const campaign = this.campaign ?? getCampaignState(); this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(this.sortieFormationAssignments); this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.sortieItemAssignments); @@ -17166,6 +17554,8 @@ export class CampScene extends Phaser.Scene { const portraitRosterVisibleCount = 8; const portraitRosterMaxScroll = Math.max(0, portraitRosterUnits.length - portraitRosterVisibleCount); const portraitRosterScroll = Phaser.Math.Clamp(this.sortieRosterScroll, 0, portraitRosterMaxScroll); + const sortieComparisonPreview = this.sortieFormationComparisonPreview(); + const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview); return { scene: this.scene.key, activeTab: this.activeTab, @@ -17217,7 +17607,16 @@ export class CampScene extends Phaser.Scene { sortieDeploymentPreview: this.sortieDeploymentPreviewDebug(), sortieFocusedUnitId: this.sortieFocusedUnitId, sortieHoveredUnitId: this.sortieHoveredUnitId ?? null, - sortieComparisonPreview: this.sortieFormationComparisonPreview() ?? null, + sortiePinnedSwapCandidateUnitId: this.sortiePinnedSwapCandidateUnitId ?? null, + sortieComparisonPreview: sortieComparisonPreview ?? null, + sortieComparisonAction: sortieComparisonAction ? { ...sortieComparisonAction, enabled: true } : null, + sortieSwapUndo: this.sortieSwapUndoState + ? { + available: sortieComparisonAction?.kind === 'undo-swap', + outgoingUnitId: this.sortieSwapUndoState.outgoingUnitId, + incomingUnitId: this.sortieSwapUndoState.incomingUnitId + } + : null, sortieFocusedUnit: this.sortieFocusedUnitSummary(), sortiePlanFeedback: this.sortiePlanFeedback, sortieChecklist,