diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 18f40a4..7e1fb5c 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -33,8 +33,10 @@ try { hasCampaignSave, loadCampaignState, normalizeCampaignReserveTrainingAssignments, + normalizeCampaignSortieFormationPresets, normalizeCampaignSortieItemAssignments, resetCampaignState, + saveCampaignState, setCampaignState, setCampaignReserveTrainingFocus, setCampaignReserveTrainingAssignment, @@ -107,6 +109,96 @@ try { cappedLaunchFormation['unit-96'] === undefined, `Expected launch sortie formation assignments without a roster filter to stay capped: ${JSON.stringify(cappedLaunchFormation)}` ); + const normalizedSortiePresets = normalizeCampaignSortieFormationPresets( + { + elite: { + selectedUnitIds: [' liu-bei ', 'guan-yu', 'liu-bei', 'ghost-unit', '', 7, 'x'.repeat(97)], + formationAssignments: { + ' liu-bei ': 'front', + 'guan-yu': 'flank', + 'zhang-fei': 'reserve', + 'ghost-unit': 'support', + broken: 'center' + }, + sortieItemAssignments: { 'liu-bei': { bean: 2 } }, + itemAssignments: { 'guan-yu': { wine: 1 } } + }, + mobile: 'corrupted', + siege: { selectedUnitIds: [], formationAssignments: { 'liu-bei': 'front' } }, + ghost: { selectedUnitIds: ['liu-bei'], formationAssignments: { 'liu-bei': 'front' } } + }, + new Set(['liu-bei', 'guan-yu', 'zhang-fei']) + ); + assert( + JSON.stringify(Object.keys(normalizedSortiePresets)) === JSON.stringify(['elite']) && + JSON.stringify(normalizedSortiePresets.elite?.selectedUnitIds) === JSON.stringify(['liu-bei', 'guan-yu']) && + JSON.stringify(normalizedSortiePresets.elite?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front', 'guan-yu': 'flank' }) && + !Object.prototype.hasOwnProperty.call(normalizedSortiePresets.elite ?? {}, 'sortieItemAssignments') && + !Object.prototype.hasOwnProperty.call(normalizedSortiePresets.elite ?? {}, 'itemAssignments'), + `Expected sortie presets to trim and dedupe ids, whitelist slots, scope roles to selected roster members, and exclude supplies: ${JSON.stringify(normalizedSortiePresets)}` + ); + const cappedSortiePresets = normalizeCampaignSortieFormationPresets({ + elite: { + selectedUnitIds: Array.from({ length: 120 }, (_, index) => `unit-${index}`), + formationAssignments: Object.fromEntries(Array.from({ length: 120 }, (_, index) => [`unit-${index}`, 'front'])), + sortieItemAssignments: { 'unit-0': { bean: 2 } } + } + }); + assert( + cappedSortiePresets.elite?.selectedUnitIds.length === 96 && + cappedSortiePresets.elite.selectedUnitIds[0] === 'unit-0' && + cappedSortiePresets.elite.selectedUnitIds[95] === 'unit-95' && + !cappedSortiePresets.elite.selectedUnitIds.includes('unit-96') && + Object.keys(cappedSortiePresets.elite.formationAssignments).length === 96 && + cappedSortiePresets.elite.formationAssignments['unit-95'] === 'front' && + cappedSortiePresets.elite.formationAssignments['unit-96'] === undefined && + !Object.prototype.hasOwnProperty.call(cappedSortiePresets.elite, 'sortieItemAssignments'), + `Expected each sortie preset and its role map to stay capped at 96 entries without persisting supplies: ${JSON.stringify(cappedSortiePresets)}` + ); + + storage.clear(); + storage.set( + campaignStorageKey, + JSON.stringify({ + version: 1, + updatedAt: '2026-07-03T11:20:00.000Z', + step: 'third-camp', + activeSaveSlot: 2, + roster: [ + { id: 'liu-bei', name: 'Liu Bei', faction: 'ally' }, + { id: 'guan-yu', name: 'Guan Yu', faction: 'ally' } + ], + sortieFormationPresets: { + elite: { + selectedUnitIds: ['liu-bei', 'guan-yu'], + formationAssignments: { 'liu-bei': 'front', 'guan-yu': 'flank' } + } + } + }) + ); + const loadedPresetState = loadCampaignState(); + const detachedPresetState = getCampaignState(); + detachedPresetState.sortieFormationPresets.elite.selectedUnitIds.push('ghost-unit'); + detachedPresetState.sortieFormationPresets.elite.formationAssignments['liu-bei'] = 'support'; + const unmutatedPresetState = getCampaignState(); + assert( + JSON.stringify(unmutatedPresetState.sortieFormationPresets.elite?.selectedUnitIds) === JSON.stringify(['liu-bei', 'guan-yu']) && + unmutatedPresetState.sortieFormationPresets.elite?.formationAssignments['liu-bei'] === 'front', + `Expected getCampaignState to deep-clone nested sortie preset members and roles: ${JSON.stringify(unmutatedPresetState.sortieFormationPresets)}` + ); + const savedPresetState = saveCampaignState(loadedPresetState, 2); + savedPresetState.sortieFormationPresets.elite.selectedUnitIds.length = 0; + savedPresetState.sortieFormationPresets.elite.formationAssignments['guan-yu'] = 'support'; + const persistedCurrentPresetState = JSON.parse(storage.get(campaignStorageKey)); + const persistedSlotPresetState = JSON.parse(storage.get(`${campaignStorageKey}:slot-2`)); + assert( + JSON.stringify(persistedCurrentPresetState.sortieFormationPresets) === JSON.stringify(persistedSlotPresetState.sortieFormationPresets) && + JSON.stringify(persistedCurrentPresetState.sortieFormationPresets.elite?.selectedUnitIds) === JSON.stringify(['liu-bei', 'guan-yu']) && + persistedCurrentPresetState.sortieFormationPresets.elite?.formationAssignments['liu-bei'] === 'front' && + persistedCurrentPresetState.sortieFormationPresets.elite?.formationAssignments['guan-yu'] === 'flank', + `Expected saving nested sortie presets to synchronize current and slot storage without sharing returned references: ${JSON.stringify({ current: persistedCurrentPresetState.sortieFormationPresets, slot: persistedSlotPresetState.sortieFormationPresets })}` + ); + const normalizedReserveDrills = normalizeCampaignReserveTrainingAssignments( { 'liu-bei': 'bond-practice', @@ -448,6 +540,8 @@ try { Array.isArray(legacy.selectedSortieUnitIds) && typeof legacy.sortieFormationAssignments === 'object' && typeof legacy.sortieItemAssignments === 'object' && + typeof legacy.sortieFormationPresets === 'object' && + Object.keys(legacy.sortieFormationPresets).length === 0 && typeof legacy.reserveTrainingAssignments === 'object', `Expected modern sortie fields to be restored: ${JSON.stringify(legacy)}` ); @@ -483,6 +577,7 @@ try { }, selectedSortieUnitIds: { corrupted: true }, sortieFormationAssignments: 'front', + sortieFormationPresets: 'elite', reserveTrainingFocus: 'ghost-focus', reserveTrainingAssignments: { 'liu-bei': 'bond-practice', @@ -534,7 +629,8 @@ try { malformed.sortieItemAssignments['zhang-fei'] === undefined && malformed.sortieItemAssignments['x'.repeat(97)] === undefined && Object.keys(malformed.sortieItemAssignments).length === 1 && - Object.keys(malformed.sortieFormationAssignments).length === 0, + Object.keys(malformed.sortieFormationAssignments).length === 0 && + Object.keys(malformed.sortieFormationPresets).length === 0, `Expected malformed inventory and sortie assignments to normalize: ${JSON.stringify(malformed)}` ); assert( @@ -582,6 +678,21 @@ try { 'guan-yu': 'support', broken: 'center' }, + sortieFormationPresets: { + elite: { + selectedUnitIds: ['ghost-unit', 'liu-bei', 'guan-yu', 'liu-bei'], + formationAssignments: { + 'liu-bei': 'front', + 'ghost-unit': 'flank', + 'guan-yu': 'support' + }, + sortieItemAssignments: { 'liu-bei': { bean: 2 } } + }, + mobile: { + selectedUnitIds: ['ghost-unit'], + formationAssignments: { 'ghost-unit': 'reserve' } + } + }, reserveTrainingAssignments: { 'liu-bei': 'bond-practice', 'ghost-unit': 'balanced', @@ -600,6 +711,10 @@ try { staleSortie.selectedSortieUnitIds[0] === 'liu-bei' && staleSortie.sortieFormationAssignments['liu-bei'] === 'front' && staleSortie.sortieFormationAssignments['ghost-unit'] === undefined && + JSON.stringify(staleSortie.sortieFormationPresets.elite?.selectedUnitIds) === JSON.stringify(['liu-bei']) && + JSON.stringify(staleSortie.sortieFormationPresets.elite?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front' }) && + staleSortie.sortieFormationPresets.mobile === undefined && + !Object.prototype.hasOwnProperty.call(staleSortie.sortieFormationPresets.elite ?? {}, 'sortieItemAssignments') && staleSortie.reserveTrainingAssignments['liu-bei'] === 'bond-practice' && staleSortie.reserveTrainingAssignments['ghost-unit'] === undefined && staleSortie.reserveTrainingAssignments['guan-yu'] === undefined && @@ -1202,7 +1317,7 @@ try { `Expected explicitly loaded corrupted slot timestamp to be normalized: ${JSON.stringify(corruptedSlot)}` ); - console.log('Verified campaign save normalization, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-growth/bond-roster/report-reference/sortie-roster/latest-history/detail recovery, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.'); + console.log('Verified campaign save normalization, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-growth/bond-roster/report-reference/sortie-roster/sortie-preset/latest-history/detail recovery, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.'); } finally { await server.close(); } diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index e4183b2..939fc3e 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -682,6 +682,48 @@ try { ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-swap-confirmed.png`, fullPage: true }); + const openPresetBookForSavePoint = await readSortiePresetControlPoint(page, 'toggle'); + await page.mouse.click(openPresetBookForSavePoint.x, openPresetBookForSavePoint.y); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortiePresetBrowser?.open === true, undefined, { timeout: 30000 }); + const emptyPresetBookState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + assert( + emptyPresetBookState?.sortiePresetBrowser?.cards?.length === 3 && + emptyPresetBookState.sortiePresetBrowser.cards.every((card) => card.saved === false) && + emptyPresetBookState?.sortieSwapUndo?.available === true, + `Expected three empty preset cards without consuming the pending swap undo: ${JSON.stringify(emptyPresetBookState?.sortiePresetBrowser)}` + ); + const saveMobilePresetPoint = await readSortiePresetControlPoint(page, 'save', 'mobile'); + await page.mouse.click(saveMobilePresetPoint.x, saveMobilePresetPoint.y); + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.sortieFormationPresets?.mobile?.selectedUnitIds?.length > 0 && + state?.sortieSwapUndo?.available === true && + state?.sortieComparisonAction?.kind === 'undo-swap'; + }, undefined, { timeout: 30000 }); + const savedMobilePresetState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const savedMobilePresetSave = await readCampaignSave(page); + const savedMobilePreset = savedMobilePresetState?.sortieFormationPresets?.mobile; + assert( + JSON.stringify(savedMobilePreset?.selectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) && + savedMobilePreset?.selectedUnitIds?.every((unitId) => Boolean(savedMobilePreset.formationAssignments?.[unitId])) && + Object.keys(savedMobilePreset ?? {}).sort().join(',') === 'formationAssignments,selectedUnitIds' && + savedMobilePreset?.sortieItemAssignments === undefined && + savedMobilePreset?.itemAssignments === undefined, + `Expected mobile preset save to materialize every selected role without storing supplies: ${JSON.stringify(savedMobilePreset)}` + ); + assert( + sameJsonValue(savedMobilePresetSave.current?.sortieFormationPresets?.mobile, savedMobilePreset) && + sameJsonValue(savedMobilePresetSave.slot1?.sortieFormationPresets?.mobile, savedMobilePreset) && + sameJsonValue(savedMobilePresetState?.sortieItemAssignments, expectedConfirmedItemAssignments) && + sameJsonValue(savedMobilePresetState?.campaign?.sortieItemAssignments, expectedConfirmedItemAssignments) && + savedMobilePresetState?.sortieSwapUndo?.available === true, + `Expected preset metadata to sync to current and slot saves without changing the applied roster, roles, supplies, or swap undo: ${JSON.stringify({ + state: savedMobilePresetState, + save: savedMobilePresetSave + })}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-preset-saved.png`, fullPage: true }); + const undoSwapActionPoint = await readSortieComparisonActionPoint(page); assert( undoSwapActionPoint.label === '되돌리기', @@ -762,6 +804,157 @@ try { `Expected undo to restore Ma Dai focus and consume the panel action exactly once: ${JSON.stringify(undoneSwapProbe)}` ); + const presetBaselineState = undoneSwapState; + const presetBaselineSave = undoneSwapSave; + const openPresetBookForApplyPoint = await readSortiePresetControlPoint(page, 'toggle'); + await page.mouse.click(openPresetBookForApplyPoint.x, openPresetBookForApplyPoint.y); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortiePresetBrowser?.open === true, undefined, { timeout: 30000 }); + const presetBookState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const mobilePresetCard = presetBookState?.sortiePresetBrowser?.cards?.find((card) => card.id === 'mobile'); + assert( + presetBookState?.sortiePresetBrowser?.cards?.length === 3 && + presetBookState?.sortiePresetBrowser?.recommendedPresetId === 'mobile' && + mobilePresetCard?.saved === true && + mobilePresetCard?.applicable === true && + mobilePresetCard?.recommended === true && + mobilePresetCard?.current === false && + presetBookState.sortiePresetBrowser.cards.filter((card) => !card.saved).length === 2, + `Expected the only saved valid preset to receive the scenario recommendation badge: ${JSON.stringify(presetBookState?.sortiePresetBrowser)}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-preset-book.png`, fullPage: true }); + + const hoverMobilePresetPoint = await readSortiePresetControlPoint(page, 'card', 'mobile'); + await page.mouse.move(hoverMobilePresetPoint.x, hoverMobilePresetPoint.y); + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.sortiePresetBrowser?.hoveredPresetId === 'mobile' && + state?.sortieComparisonPreview?.source === 'hover-preset' && + state?.sortieComparisonPreview?.presetId === 'mobile'; + }, undefined, { timeout: 30000 }); + const hoverPresetProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const view = scene?.sortieComparisonPanelView; + return { + state: window.__HEROS_DEBUG__?.camp(), + panel: { + title: view?.title?.text ?? null, + headline: view?.headline?.text ?? null, + detail: view?.detail?.text ?? null + } + }; + }); + const hoverPresetState = hoverPresetProbe.state; + const hoverPresetSave = await readCampaignSave(page); + const hoverProjectionCard = hoverPresetState?.sortiePresetBrowser?.cards?.find((card) => card.id === 'mobile'); + assert( + hoverPresetState?.sortieComparisonPreview?.mode === 'preset' && + hoverPresetState?.sortieComparisonPreview?.metrics?.length === 4 && + hoverPresetState.sortieComparisonPreview.metrics[0]?.label === '공격 합' && + hoverPresetState.sortieComparisonPreview.metrics[1]?.label === '지력 합' && + hoverPresetState.sortieComparisonPreview.metrics[2]?.label === '평균 기동' && + hoverPresetProbe.panel.title === '기동 편성책 비교' && + hoverPresetProbe.panel.headline?.includes('유지') && + hoverPresetProbe.panel.detail?.includes('실제 편성 미변경'), + `Expected a whole-formation, non-destructive preset comparison: ${JSON.stringify(hoverPresetProbe)}` + ); + assert( + JSON.stringify(hoverPresetState?.selectedSortieUnitIds) === JSON.stringify(presetBaselineState?.selectedSortieUnitIds) && + sameJsonValue(hoverPresetState?.sortieFormationAssignments, presetBaselineState?.sortieFormationAssignments) && + sameJsonValue(hoverPresetState?.sortieItemAssignments, presetBaselineState?.sortieItemAssignments) && + sameJsonValue(hoverPresetSave.current, presetBaselineSave.current) && + sameJsonValue(hoverPresetSave.slot1, presetBaselineSave.slot1), + `Expected preset hover to preserve runtime, campaign, current save, and slot save: ${JSON.stringify(hoverPresetState)}` + ); + + const compareMobilePresetPoint = await readSortiePresetControlPoint(page, 'compare', 'mobile'); + await page.mouse.click(compareMobilePresetPoint.x, compareMobilePresetPoint.y); + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.sortiePresetBrowser?.pinnedPresetId === 'mobile' && + state?.sortieComparisonPreview?.source === 'pinned-preset' && + state?.sortieComparisonAction?.kind === 'confirm-preset'; + }, undefined, { timeout: 30000 }); + const pinnedPresetState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const pinnedPresetSave = await readCampaignSave(page); + const pinnedProjectionCard = pinnedPresetState?.sortiePresetBrowser?.cards?.find((card) => card.id === 'mobile'); + assert( + JSON.stringify(pinnedProjectionCard?.projectedSelectedUnitIds) === JSON.stringify(expectedProjectedSwapIds) && + sameJsonValue(pinnedProjectionCard?.projectedFormationAssignments, savedMobilePreset?.formationAssignments) && + pinnedPresetState?.sortieComparisonAction?.label === '적용 확정' && + sameJsonValue(pinnedPresetSave.current, presetBaselineSave.current) && + sameJsonValue(pinnedPresetSave.slot1, presetBaselineSave.slot1), + `Expected pinning the preset to keep every saved value unchanged until confirmation: ${JSON.stringify(pinnedPresetState)}` + ); + const expectedPresetSelectedIds = [...(pinnedProjectionCard?.projectedSelectedUnitIds ?? [])]; + const expectedPresetFormationAssignments = { ...(pinnedProjectionCard?.projectedFormationAssignments ?? {}) }; + const expectedPresetItemAssignments = structuredClone(pinnedProjectionCard?.projectedItemAssignments ?? {}); + const applyPresetActionPoint = await readSortieComparisonActionPoint(page); + assert(applyPresetActionPoint.label === '적용 확정', `Expected preset confirmation label: ${JSON.stringify(applyPresetActionPoint)}`); + await page.mouse.click(applyPresetActionPoint.x, applyPresetActionPoint.y); + await page.waitForFunction((expectedIds) => { + const state = window.__HEROS_DEBUG__?.camp(); + return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(expectedIds) && + state?.sortiePresetUndo?.available === true && + state?.sortieComparisonAction?.kind === 'undo-preset' && + state?.sortiePresetBrowser?.open === false; + }, expectedPresetSelectedIds, { timeout: 30000 }); + const appliedPresetState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const appliedPresetSave = await readCampaignSave(page); + assert( + JSON.stringify(appliedPresetState?.selectedSortieUnitIds) === JSON.stringify(expectedPresetSelectedIds) && + JSON.stringify(appliedPresetState?.campaign?.selectedSortieUnitIds) === JSON.stringify(expectedPresetSelectedIds) && + JSON.stringify(appliedPresetSave.current?.selectedSortieUnitIds) === JSON.stringify(expectedPresetSelectedIds) && + JSON.stringify(appliedPresetSave.slot1?.selectedSortieUnitIds) === JSON.stringify(expectedPresetSelectedIds) && + sameJsonValue(appliedPresetState?.sortieFormationAssignments, expectedPresetFormationAssignments) && + sameJsonValue(appliedPresetState?.campaign?.sortieFormationAssignments, expectedPresetFormationAssignments) && + sameJsonValue(appliedPresetSave.current?.sortieFormationAssignments, expectedPresetFormationAssignments) && + sameJsonValue(appliedPresetSave.slot1?.sortieFormationAssignments, expectedPresetFormationAssignments), + `Expected preset confirmation to persist the canonical roster and stored roles everywhere: ${JSON.stringify(appliedPresetState)}` + ); + assert( + sameJsonValue(appliedPresetState?.sortieItemAssignments, expectedPresetItemAssignments) && + sameJsonValue(appliedPresetState?.campaign?.sortieItemAssignments, expectedPresetItemAssignments) && + sameJsonValue(appliedPresetSave.current?.sortieItemAssignments, expectedPresetItemAssignments) && + sameJsonValue(appliedPresetSave.slot1?.sortieItemAssignments, expectedPresetItemAssignments) && + appliedPresetState?.sortieItemAssignments?.['ma-dai'] === undefined && + appliedPresetState?.sortieItemAssignments?.['guan-yu'] === undefined && + appliedPresetState?.sortiePresetUndo?.presetId === 'mobile', + `Expected preset apply to preserve only shared officers' supplies without auto-transferring them: ${JSON.stringify({ + expected: expectedPresetItemAssignments, + actual: appliedPresetState?.sortieItemAssignments + })}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-preset-applied.png`, fullPage: true }); + + const undoPresetActionPoint = await readSortieComparisonActionPoint(page); + assert(undoPresetActionPoint.label === '되돌리기', `Expected preset undo label: ${JSON.stringify(undoPresetActionPoint)}`); + await page.mouse.click(undoPresetActionPoint.x, undoPresetActionPoint.y); + await page.waitForFunction((baselineIds) => { + const state = window.__HEROS_DEBUG__?.camp(); + return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(baselineIds) && + state?.sortiePresetUndo === null && + state?.sortieComparisonAction === null; + }, presetBaselineState?.selectedSortieUnitIds ?? [], { timeout: 30000 }); + const undonePresetState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const undonePresetSave = await readCampaignSave(page); + assert( + JSON.stringify(undonePresetState?.selectedSortieUnitIds) === JSON.stringify(presetBaselineState?.selectedSortieUnitIds) && + sameJsonValue(undonePresetState?.sortieFormationAssignments, presetBaselineState?.sortieFormationAssignments) && + sameJsonValue(undonePresetState?.sortieItemAssignments, presetBaselineState?.sortieItemAssignments) && + JSON.stringify(undonePresetSave.current?.selectedSortieUnitIds) === JSON.stringify(presetBaselineSave.current?.selectedSortieUnitIds) && + JSON.stringify(undonePresetSave.slot1?.selectedSortieUnitIds) === JSON.stringify(presetBaselineSave.slot1?.selectedSortieUnitIds) && + sameJsonValue(undonePresetSave.current?.sortieFormationAssignments, presetBaselineSave.current?.sortieFormationAssignments) && + sameJsonValue(undonePresetSave.slot1?.sortieFormationAssignments, presetBaselineSave.slot1?.sortieFormationAssignments) && + sameJsonValue(undonePresetSave.current?.sortieItemAssignments, presetBaselineSave.current?.sortieItemAssignments) && + sameJsonValue(undonePresetSave.slot1?.sortieItemAssignments, presetBaselineSave.slot1?.sortieItemAssignments) && + sameJsonValue(undonePresetSave.current?.sortieFormationPresets?.mobile, savedMobilePreset) && + sameJsonValue(undonePresetSave.slot1?.sortieFormationPresets?.mobile, savedMobilePreset), + `Expected one-time preset undo to restore the exact roster, roles, and supplies while retaining the saved preset: ${JSON.stringify({ + state: undonePresetState, + save: undonePresetSave + })}` + ); + const midFormationFirstPage = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePortraitRosterView); await page.mouse.click(658, 196); await page.waitForFunction((previousScroll) => { @@ -1280,6 +1473,44 @@ async function readSortieComparisonActionPoint(page) { return probe; } +async function readSortiePresetControlPoint(page, control, presetId) { + const probe = await page.evaluate(({ control, presetId }) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp(); + const canvas = document.querySelector('canvas'); + const presetCard = state?.sortiePresetBrowser?.cards?.find((card) => card.id === presetId); + const bounds = control === 'toggle' + ? state?.sortiePresetBrowser?.toggleButtonBounds + : control === 'close' + ? state?.sortiePresetBrowser?.closeButtonBounds + : control === 'card' + ? presetCard?.backgroundBounds + : control === 'compare' + ? presetCard?.compareButtonBounds + : presetCard?.saveButtonBounds; + if (!scene || !canvas || !bounds) { + return { ready: false, control, presetId: presetId ?? null, 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, + 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 preset ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}` + ); + return probe; +} + async function readBattleDoctrineProbe(page) { return page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index e463131..4ced9fb 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -113,6 +113,7 @@ import { import { applyCampVisitReward, applyCampBondExp, + campaignSortiePresetIds, campaignReserveTrainingFocusDefinitions, defaultCampaignReserveTrainingFocusId, ensureCampaignRosterUnits, @@ -130,6 +131,8 @@ import { type CampaignReserveTrainingFocusDefinition, type CampaignReserveTrainingFocusId, type CampaignSortieItemAssignments, + type CampaignSortieFormationPresets, + type CampaignSortiePresetId, type FirstBattleReport } from '../state/campaignState'; import { palette } from '../ui/palette'; @@ -346,9 +349,10 @@ type SortieComparisonMetric = { }; type SortieFormationComparisonPreview = { - source: 'focus' | 'hover-add' | 'hover-swap' | 'pinned-swap' | 'hover-blocked'; - mode: SortieUnitSynergyPreview['mode'] | 'swap'; + source: 'focus' | 'hover-add' | 'hover-swap' | 'pinned-swap' | 'hover-blocked' | 'hover-preset' | 'pinned-preset'; + mode: SortieUnitSynergyPreview['mode'] | 'swap' | 'preset'; unitId: string; + presetId?: CampaignSortiePresetId; incomingUnitId: string | null; incomingUnitName: string | null; outgoingUnitId: string | null; @@ -406,11 +410,37 @@ type SortieSwapUndoState = { applied: SortieConfigurationSnapshot; }; +type SortiePresetProjection = { + presetId: CampaignSortiePresetId; + selectedUnitIds: string[]; + formationAssignments: SortieFormationAssignments; + itemAssignments: CampaignSortieItemAssignments; + omittedUnitNames: string[]; +}; + +type SortiePresetUndoState = { + presetId: CampaignSortiePresetId; + before: SortieConfigurationSnapshot; + applied: SortieConfigurationSnapshot; +}; + +type SortiePresetCardView = { + background: Phaser.GameObjects.Rectangle; + compareButton: Phaser.GameObjects.Rectangle; + saveButton: Phaser.GameObjects.Rectangle; +}; + +type SortiePresetBrowserView = { + closeButton: Phaser.GameObjects.Rectangle; + cards: Partial>; +}; + type SortieComparisonAction = { - kind: 'confirm-swap' | 'undo-swap'; + kind: 'confirm-swap' | 'undo-swap' | 'confirm-preset' | 'undo-preset'; label: string; - outgoingUnitId: string; - incomingUnitId: string; + outgoingUnitId?: string; + incomingUnitId?: string; + presetId?: CampaignSortiePresetId; }; type CampaignTimelineChapter = { @@ -712,6 +742,17 @@ const campCompanionRoleDefinitions: Omit[] = [ { role: 'reserve', label: '기록', description: '남은 기록과 공명 정리 담당' } ]; +const sortiePresetDefinitions: { + id: CampaignSortiePresetId; + label: string; + summary: string; + accent: number; +}[] = [ + { id: 'elite', label: '정예', summary: '주력·공명 중심', accent: 0xd8b15f }, + { id: 'mobile', label: '기동', summary: '우회·지형 대응', accent: 0x72a7d8 }, + { id: 'siege', label: '공성', summary: '전열·원거리 중심', accent: 0xc87552 } +]; + const defaultSortieRule: SortieRuleDefinition = { maxUnits: maxSortieUnits, requiredUnitIds: defaultRequiredSortieUnitIds, @@ -10862,6 +10903,13 @@ export class CampScene extends Phaser.Scene { private sortieHoveredUnitId?: string; private sortiePinnedSwapCandidateUnitId?: string; private sortieSwapUndoState?: SortieSwapUndoState; + private sortiePresetBrowserOpen = false; + private sortieHoveredPresetId?: CampaignSortiePresetId; + private sortiePinnedPresetId?: CampaignSortiePresetId; + private sortiePresetOverwriteConfirmId?: CampaignSortiePresetId; + private sortiePresetUndoState?: SortiePresetUndoState; + private sortiePresetBrowserView?: SortiePresetBrowserView; + private sortiePresetToggleButton?: Phaser.GameObjects.Rectangle; private sortieComparisonPanelView?: SortieComparisonPanelView; private sortieRosterScroll = 0; private sortiePlanFeedback = ''; @@ -10886,6 +10934,13 @@ export class CampScene extends Phaser.Scene { this.sortieComparisonPanelView = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; this.sortieSwapUndoState = undefined; + this.sortiePresetBrowserOpen = false; + this.sortieHoveredPresetId = undefined; + this.sortiePinnedPresetId = undefined; + this.sortiePresetOverwriteConfirmId = undefined; + this.sortiePresetUndoState = undefined; + this.sortiePresetBrowserView = undefined; + this.sortiePresetToggleButton = undefined; this.saveSlotObjects = []; this.saveSlotConfirmObjects = []; this.pendingSaveSlot = undefined; @@ -12236,6 +12291,8 @@ export class CampScene extends Phaser.Scene { const returnToSortiePrep = this.sortieObjects.length > 0; this.sortieSwapUndoState = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; + this.sortiePresetUndoState = undefined; + this.closeSortiePresetBrowserState(); this.hideCampSaveSlotPanel(); this.campaign = saveCampaignState(getCampaignState(), slot); if (returnToSortiePrep) { @@ -12564,6 +12621,7 @@ export class CampScene extends Phaser.Scene { this.sortiePrepStep = step; this.sortiePinnedSwapCandidateUnitId = undefined; this.sortieHoveredUnitId = undefined; + this.closeSortiePresetBrowserState(); this.sortieRosterScroll = 0; soundDirector.playSelect(); this.showSortiePrep(); @@ -12738,7 +12796,11 @@ export class CampScene extends Phaser.Scene { const gap = 18; const rosterWidth = 610; const planWidth = width - rosterWidth - gap; - this.renderCampaignSortiePortraitRoster(x, y, rosterWidth, height, depth); + if (this.sortiePresetBrowserOpen) { + this.renderCampaignSortiePresetBrowser(x, y, rosterWidth, height, depth); + } else { + this.renderCampaignSortiePortraitRoster(x, y, rosterWidth, height, depth); + } this.renderSortieFormationPlan(x + rosterWidth + gap, y, planWidth, 286, depth); this.renderCampaignSortieComparisonPanel(x + rosterWidth + gap, y + 304, planWidth, height - 304, depth); } @@ -12945,6 +13007,175 @@ export class CampScene extends Phaser.Scene { }); } + private renderCampaignSortiePresetBrowser(x: number, y: number, width: number, height: number, depth: number) { + const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.98)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, palette.gold, 0.62); + + this.trackSortie(this.add.text(x + 18, y + 13, '부대 편성책', this.textStyle(19, '#f2e3bf', true))).setDepth(depth + 1); + this.trackSortie( + this.add.text(x + 146, y + 18, '명단·역할 저장 · 보급 제외', this.textStyle(11, '#9fb0bf', true)) + ).setDepth(depth + 1); + + const closeButton = this.trackSortie(this.add.rectangle(x + width - 96, y + 9, 78, 30, 0x18232e, 0.96)); + closeButton.setOrigin(0); + closeButton.setDepth(depth + 2); + closeButton.setStrokeStyle(1, palette.blue, 0.58); + closeButton.setInteractive({ useHandCursor: true }); + closeButton.on('pointerover', () => closeButton.setFillStyle(0x243442, 1).setStrokeStyle(1, palette.gold, 0.9)); + closeButton.on('pointerout', () => closeButton.setFillStyle(0x18232e, 0.96).setStrokeStyle(1, palette.blue, 0.58)); + closeButton.on('pointerdown', () => this.toggleSortiePresetBrowser(false)); + this.trackSortie(this.add.text(x + width - 57, y + 24, '무장 목록', this.textStyle(11, '#d8ecff', true))) + .setOrigin(0.5) + .setDepth(depth + 3); + + const cards: SortiePresetBrowserView['cards'] = {}; + const recommendedPresetId = this.recommendedSortiePresetId(); + const cardX = x + 18; + const cardWidth = width - 36; + const cardHeight = 104; + const cardGap = 8; + sortiePresetDefinitions.forEach((definition, index) => { + const cardY = y + 50 + index * (cardHeight + cardGap); + const preset = this.sortieFormationPresets()[definition.id]; + const projection = this.sortiePresetProjection(definition.id); + const pending = this.sortiePinnedPresetId === definition.id; + const hovered = this.sortieHoveredPresetId === definition.id; + const current = Boolean(projection && this.sortiePresetProjectionMatchesCurrent(projection)); + const recommended = recommendedPresetId === definition.id; + const cardFill = pending ? 0x263229 : current ? 0x1d3028 : preset ? 0x151f2a : 0x111820; + const cardStroke = pending || hovered ? palette.gold : current ? palette.green : preset ? definition.accent : 0x53606c; + const card = this.trackSortie(this.add.rectangle(cardX, cardY, cardWidth, cardHeight, cardFill, preset ? 0.98 : 0.82)); + card.setOrigin(0); + card.setDepth(depth + 1); + card.setStrokeStyle(pending || hovered ? 2 : 1, cardStroke, pending || hovered ? 0.96 : 0.52); + card.setInteractive({ useHandCursor: Boolean(projection) }); + card.on('pointerover', () => { + if (!projection) { + return; + } + this.sortieHoveredPresetId = definition.id; + card.setFillStyle(0x22302d, 1).setStrokeStyle(2, palette.gold, 0.96); + this.refreshCampaignSortieComparisonPanel(); + }); + card.on('pointerout', () => { + if (this.sortieHoveredPresetId === definition.id) { + this.sortieHoveredPresetId = undefined; + } + card.setFillStyle(cardFill, preset ? 0.98 : 0.82).setStrokeStyle(pending ? 2 : 1, cardStroke, pending ? 0.96 : 0.52); + this.refreshCampaignSortieComparisonPanel(); + }); + card.on('pointerdown', () => { + if (projection) { + this.pinSortiePreset(definition.id); + } + }); + + const accent = this.trackSortie(this.add.rectangle(cardX + 4, cardY + 4, 5, cardHeight - 8, definition.accent, preset ? 0.94 : 0.42)); + accent.setOrigin(0); + accent.setDepth(depth + 2); + this.trackSortie(this.add.text(cardX + 22, cardY + 11, definition.label, this.textStyle(18, preset ? '#f2e3bf' : '#a6afb9', true))).setDepth(depth + 2); + this.trackSortie(this.add.text(cardX + 78, cardY + 16, definition.summary, this.textStyle(10, '#9fb0bf', true))).setDepth(depth + 2); + + const badge = current ? '현재 적용' : recommended ? '이번 전투 추천' : pending ? '비교 중' : ''; + if (badge) { + const badgeColor = current ? '#a8ffd0' : '#ffdf7b'; + this.trackSortie(this.add.text(cardX + cardWidth - 16, cardY + 13, badge, this.textStyle(10, badgeColor, true))) + .setOrigin(1, 0) + .setDepth(depth + 2); + } + + const storedNames = preset?.selectedUnitIds.map((unitId) => this.unitName(unitId)) ?? []; + const memberLine = preset + ? this.sortiePresetMemberLine(storedNames) + : '비어 있음 · 현재 편성을 저장하십시오.'; + this.trackSortie(this.add.text(cardX + 22, cardY + 39, this.compactText(memberLine, 54), this.textStyle(12, preset ? '#d4dce6' : '#77818c', Boolean(preset)))) + .setDepth(depth + 2); + + const snapshot = projection + ? this.sortieSynergySnapshot(projection.selectedUnitIds, this.nextSortieScenario(), projection.formationAssignments) + : undefined; + const unavailableLine = projection?.omittedUnitNames.length + ? ` · 결원 ${projection.omittedUnitNames.length}` + : preset && !projection + ? ' · 적용 불가' + : ''; + const statusLine = snapshot + ? `출전 ${projection?.selectedUnitIds.length ?? 0}명 · 역할 ${snapshot.coveredRoleCount}/3 · 공명 ${snapshot.activeBondCount} · 지형 ${snapshot.terrainGrade}${unavailableLine}` + : preset + ? `저장 ${preset.selectedUnitIds.length}명${unavailableLine}` + : '세 전술안은 현재 장면과 세이브 슬롯에 함께 보존됩니다.'; + this.trackSortie(this.add.text(cardX + 22, cardY + 62, this.compactText(statusLine, 58), this.textStyle(10, snapshot ? '#d8b15f' : '#7f8994', true))) + .setDepth(depth + 2); + + const compareEnabled = Boolean(projection && !current); + const compareButton = this.renderSortiePresetBrowserButton( + current ? '적용 중' : pending ? '비교 고정' : '비교', + cardX + cardWidth - 174, + cardY + 75, + 72, + 21, + compareEnabled, + pending, + () => this.pinSortiePreset(definition.id), + depth + 3 + ); + const confirmOverwrite = this.sortiePresetOverwriteConfirmId === definition.id; + const saveButton = this.renderSortiePresetBrowserButton( + confirmOverwrite ? '저장 확정' : preset ? '덮어쓰기' : '현재 저장', + cardX + cardWidth - 94, + cardY + 75, + 78, + 21, + this.selectedSortieUnitIds.length > 0, + confirmOverwrite, + () => this.requestSaveCurrentSortiePreset(definition.id), + depth + 3 + ); + cards[definition.id] = { background: card, compareButton, saveButton }; + }); + + this.trackSortie( + this.add.text( + x + 18, + y + height - 24, + '카드 비교 → 오른쪽 수치 확인 → 적용 확정 · 보급은 공통 무장만 유지', + this.textStyle(10, '#9fb0bf', true) + ) + ).setDepth(depth + 1); + this.sortiePresetBrowserView = { closeButton, cards }; + } + + private renderSortiePresetBrowserButton( + label: string, + x: number, + y: number, + width: number, + height: number, + enabled: boolean, + active: boolean, + action: () => void, + depth: number + ) { + const fill = active ? 0x304333 : enabled ? 0x18232e : 0x111820; + const stroke = active ? palette.green : enabled ? palette.blue : 0x53606c; + const button = this.trackSortie(this.add.rectangle(x, y, width, height, fill, enabled ? 0.98 : 0.6)); + button.setOrigin(0); + button.setDepth(depth); + button.setStrokeStyle(1, stroke, active ? 0.86 : enabled ? 0.58 : 0.3); + if (enabled) { + button.setInteractive({ useHandCursor: true }); + button.on('pointerover', () => button.setFillStyle(active ? 0x3a5540 : 0x263442, 1).setStrokeStyle(1, palette.gold, 0.94)); + button.on('pointerout', () => button.setFillStyle(fill, 0.98).setStrokeStyle(1, stroke, active ? 0.86 : 0.58)); + button.on('pointerdown', action); + } + this.trackSortie(this.add.text(x + width / 2, y + height / 2, label, this.textStyle(9, active ? '#e7ffd9' : enabled ? '#d8ecff' : '#77818c', true))) + .setOrigin(0.5) + .setDepth(depth + 1); + return button; + } + private renderCampaignSortieComparisonPanel(x: number, y: number, width: number, height: number, depth: number) { const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.96)); bg.setOrigin(0); @@ -12970,7 +13201,7 @@ export class CampScene extends Phaser.Scene { actionButton.on('pointerout', () => { const action = this.sortieComparisonAction(); if (actionButton.visible && action) { - const undo = action.kind === 'undo-swap'; + const undo = action.kind === 'undo-swap' || action.kind === 'undo-preset'; actionButton .setFillStyle(undo ? 0x243442 : 0x263a2d, 0.98) .setStrokeStyle(1, undo ? palette.blue : palette.green, 0.78); @@ -13026,12 +13257,13 @@ 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 isUndoAction = action?.kind === 'undo-swap' || action?.kind === 'undo-preset'; const current = preview?.current ?? this.sortieSynergySnapshot(); 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 isHoverPreview = preview?.source === 'hover-add' || preview?.source === 'hover-swap' || preview?.source === 'pinned-swap' || + preview?.source === 'hover-preset' || preview?.source === 'pinned-preset'; const lostRole = Boolean(comparison?.lostRoles.length); const completeFormation = projected.coveredRoleCount === coreSortieSynergyRoles.length; view.background.setStrokeStyle( @@ -13053,8 +13285,12 @@ export class CampScene extends Phaser.Scene { return; } - const title = isUndoAction && this.sortieSwapUndoState - ? `교체 완료 · ${this.sortieSwapUndoState.outgoingUnitName} → ${this.sortieSwapUndoState.incomingUnitName}` + const title = action?.kind === 'undo-preset' && this.sortiePresetUndoState + ? `${this.sortiePresetDefinition(this.sortiePresetUndoState.presetId).label} 적용 완료` + : isUndoAction && this.sortieSwapUndoState + ? `교체 완료 · ${this.sortieSwapUndoState.outgoingUnitName} → ${this.sortieSwapUndoState.incomingUnitName}` + : preview.source === 'pinned-preset' || preview.source === 'hover-preset' + ? `${this.sortiePresetDefinition(preview.presetId ?? 'elite').label} 편성책 비교` : preview.source === 'pinned-swap' ? `교체안 고정 · ${preview.outgoingUnitName} → ${preview.incomingUnitName}` : preview.source === 'hover-swap' @@ -13069,7 +13305,8 @@ export class CampScene extends Phaser.Scene { 38 ) ); - const headlineColor = isUndoAction || preview.source === 'hover-swap' || preview.source === 'pinned-swap' || preview.mode === 'add' + const headlineColor = isUndoAction || preview.source === 'hover-swap' || preview.source === 'pinned-swap' || + preview.source === 'hover-preset' || preview.source === 'pinned-preset' || preview.mode === 'add' ? '#a8ffd0' : preview.mode === 'remove' ? '#ff9d7d' @@ -13078,8 +13315,10 @@ export class CampScene extends Phaser.Scene { : preview.mode === 'blocked' ? '#87919c' : '#d4dce6'; - const headline = isUndoAction && this.sortieSwapUndoState - ? `${this.sortieSwapUndoState.incomingUnitName} 편성 완료 · 장비·보급 재점검` + const headline = action?.kind === 'undo-preset' && this.sortiePresetUndoState + ? `명단 ${current.selectedUnitIds.length}명·역할 저장값 적용 · 장비·보급 재점검` + : isUndoAction && this.sortieSwapUndoState + ? `${this.sortieSwapUndoState.incomingUnitName} 편성 완료 · 장비·보급 재점검` : preview.headline; view.headline.setText(this.compactText(headline, action ? 44 : 64)).setColor(headlineColor); @@ -13103,6 +13342,16 @@ export class CampScene extends Phaser.Scene { .setColor(color); }); + if (action?.kind === 'undo-preset' && this.sortiePresetUndoState) { + view.impact + .setText(`현재 역할 ${current.coveredRoleCount}/3 · 공명 ${current.activeBondCount} · 지형 ${current.terrainGrade}`) + .setColor('#a8ffd0'); + view.detail + .setText('유지 가능한 보급만 보존했습니다. 직전 적용은 한 번 되돌릴 수 있습니다.') + .setColor('#ffdf7b'); + return; + } + if (isUndoAction && this.sortieSwapUndoState) { view.impact .setText(`현재 역할 ${current.coveredRoleCount}/3 · 공명 ${current.activeBondCount} · 지형 ${current.terrainGrade}`) @@ -13169,7 +13418,7 @@ export class CampScene extends Phaser.Scene { return; } - const undo = action.kind === 'undo-swap'; + const undo = action.kind === 'undo-swap' || action.kind === 'undo-preset'; view.actionButton .setInteractive({ useHandCursor: true }) .setFillStyle(undo ? 0x243442 : 0x263a2d, 0.98) @@ -13180,6 +13429,19 @@ export class CampScene extends Phaser.Scene { private sortieComparisonAction( preview = this.sortieFormationComparisonPreview() ): SortieComparisonAction | undefined { + if ( + preview?.source === 'pinned-preset' && + preview.mode === 'preset' && + preview.presetId && + this.sortiePresetProjection(preview.presetId) + ) { + return { + kind: 'confirm-preset', + label: '적용 확정', + presetId: preview.presetId + }; + } + if ( preview?.source === 'pinned-swap' && preview.mode === 'swap' && @@ -13195,6 +13457,23 @@ export class CampScene extends Phaser.Scene { }; } + if ( + preview && + (preview.source === 'hover-add' || preview.source === 'hover-swap' || + preview.source === 'hover-blocked' || preview.source === 'hover-preset') + ) { + return undefined; + } + + const presetUndo = this.sortiePresetUndoState; + if (presetUndo && this.sortiePersistedConfigurationMatches(presetUndo.applied)) { + return { + kind: 'undo-preset', + label: '되돌리기', + presetId: presetUndo.presetId + }; + } + const undo = this.sortieSwapUndoState; if (undo && this.sortiePersistedConfigurationMatches(undo.applied)) { return { @@ -13209,12 +13488,20 @@ export class CampScene extends Phaser.Scene { private handleSortieComparisonAction() { const action = this.sortieComparisonAction(); + if (action?.kind === 'confirm-preset' && action.presetId) { + this.confirmPinnedSortiePreset(action.presetId); + return; + } if (action?.kind === 'confirm-swap') { this.confirmPinnedSortieSwap(); return; } if (action?.kind === 'undo-swap') { this.undoLastSortieSwap(); + return; + } + if (action?.kind === 'undo-preset') { + this.undoLastSortiePreset(); } } @@ -13867,9 +14154,26 @@ export class CampScene extends Phaser.Scene { const plan = this.sortiePlanSummary(); const scenario = this.nextSortieScenario(); const hasBattle = Boolean(scenario); - const canRecommend = this.canApplyRecommendedSortiePlan(scenario); + const canUsePresetBook = this.usesStagedSortiePrep() && this.sortiePrepStep === 'formation' && !this.isFirstSortiePrepSlice(); + const canRecommend = this.canApplyRecommendedSortiePlan(scenario) && !this.sortiePresetBrowserOpen; this.trackSortie(this.add.text(x + 18, y + 14, hasBattle ? '출전 슬롯' : '동행 명단', this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1); - this.renderSortiePanelButton(hasBattle ? '추천 편성' : '추천 동행', x + width - 104, y + 10, 86, 22, canRecommend, false, () => this.applyRecommendedSortiePlan(), depth + 1); + if (canUsePresetBook) { + this.renderSortiePanelButton('추천', x + width - 142, y + 10, 54, 22, canRecommend, false, () => this.applyRecommendedSortiePlan(), depth + 1); + this.sortiePresetToggleButton = this.renderSortiePanelButton( + '편성책', + x + width - 82, + y + 10, + 64, + 22, + true, + this.sortiePresetBrowserOpen, + () => this.toggleSortiePresetBrowser(), + depth + 1 + ); + } else { + this.sortiePresetToggleButton = undefined; + this.renderSortiePanelButton(hasBattle ? '추천 편성' : '추천 동행', x + width - 104, y + 10, 86, 22, canRecommend, false, () => this.applyRecommendedSortiePlan(), depth + 1); + } this.trackSortie( this.add.text( x + 18, @@ -13917,16 +14221,18 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(focused ? 2 : 1, focused ? palette.gold : filled ? palette.green : 0x53606c, focused ? 0.92 : filled ? 0.58 : 0.34); - bg.setInteractive({ useHandCursor: true }); - bg.on('pointerdown', () => { - if (!unit) { - this.showCampNotice(`왼쪽 명단에서 ${hasBattle ? '출전' : '동행'}할 무장을 선택하세요.`); - return; - } - this.sortieFocusedUnitId = unit.id; - soundDirector.playSelect(); - this.showSortiePrep(); - }); + if (!this.sortiePresetBrowserOpen) { + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerdown', () => { + if (!unit) { + this.showCampNotice(`왼쪽 명단에서 ${hasBattle ? '출전' : '동행'}할 무장을 선택하세요.`); + return; + } + this.sortieFocusedUnitId = unit.id; + soundDirector.playSelect(); + this.showSortiePrep(); + }); + } const labelColor = filled ? '#f2e3bf' : '#7f8994'; this.trackSortie(this.add.text(slotX + 8, slotY + 5, `${index + 1}`, this.textStyle(10, labelColor, true))).setDepth(depth + 1); @@ -13943,7 +14249,7 @@ export class CampScene extends Phaser.Scene { private renderSortieRoleQuickButtons(x: number, y: number, width: number, height: number, depth: number) { const focusedUnit = this.sortieFocusedUnit(); - const enabled = Boolean(focusedUnit && this.isSortieSelected(focusedUnit.id)); + const enabled = Boolean(focusedUnit && this.isSortieSelected(focusedUnit.id) && !this.sortiePresetBrowserOpen); const hasBattle = Boolean(this.nextSortieScenario()); const gap = 6; const buttonWidth = Math.floor((width - gap * (sortieFormationSlotDefinitions.length - 1)) / sortieFormationSlotDefinitions.length); @@ -13955,8 +14261,10 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, active ? palette.green : 0x53606c, active ? 0.78 : 0.36); - bg.setInteractive({ useHandCursor: true }); - bg.on('pointerdown', () => this.assignFocusedSortieRole(slot.role)); + if (enabled) { + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerdown', () => this.assignFocusedSortieRole(slot.role)); + } this.trackSortie(this.add.text(buttonX + buttonWidth / 2, y + 5, roleCopy.label, this.textStyle(9, active ? '#a8ffd0' : enabled ? '#c8d2dd' : '#77818c', true))).setOrigin(0.5, 0).setDepth(depth + 1); }); } @@ -14206,6 +14514,7 @@ export class CampScene extends Phaser.Scene { text.setInteractive({ useHandCursor: true }); text.on('pointerdown', action); } + return bg; } private renderSortieSupplyAssignment(unit: UnitData, x: number, y: number, width: number, depth: number) { @@ -14304,13 +14613,14 @@ export class CampScene extends Phaser.Scene { private sortieSynergySnapshot( selectedUnitIds: readonly string[] = this.selectedSortieUnitIds, - scenario = this.nextSortieScenario() + scenario = this.nextSortieScenario(), + formationAssignments: SortieFormationAssignments = this.sortieFormationAssignments ) { return evaluateSortieSynergy({ members: this.sortieRosterUnits().map((unit) => ({ id: unit.id, name: unit.name, - role: this.sortieFormationRole(unit, scenario), + role: this.sortieFormationRole(unit, scenario, formationAssignments), terrainScore: this.sortieTerrainScore(unit, scenario) })), bonds: this.sortieSynergyBonds(scenario), @@ -14319,11 +14629,220 @@ export class CampScene extends Phaser.Scene { }); } + private sortieFormationPresets(): CampaignSortieFormationPresets { + return this.campaign?.sortieFormationPresets ?? getCampaignState().sortieFormationPresets; + } + + private sortiePresetDefinition(presetId: CampaignSortiePresetId) { + return sortiePresetDefinitions.find((definition) => definition.id === presetId) ?? sortiePresetDefinitions[0]; + } + + private sortiePresetMemberLine(unitNames: string[]) { + if (unitNames.length <= 4) { + return unitNames.join(' · '); + } + return `${unitNames.slice(0, 4).join(' · ')} 외 ${unitNames.length - 4}명`; + } + + private sortiePresetProjection( + presetId: CampaignSortiePresetId, + scenario = this.nextSortieScenario() + ): SortiePresetProjection | undefined { + const preset = this.sortieFormationPresets()[presetId]; + if (!preset?.selectedUnitIds.length) { + return undefined; + } + + const availableUnits = this.sortieAllies(); + const availableIds = new Set(availableUnits.map((unit) => unit.id)); + const eligiblePresetIds = preset.selectedUnitIds.filter((unitId) => availableIds.has(unitId)); + if (eligiblePresetIds.length === 0) { + return undefined; + } + + const selectedUnitIds = this.normalizedSortieUnitIds(eligiblePresetIds); + if (!selectedUnitIds.some((unitId) => eligiblePresetIds.includes(unitId))) { + return undefined; + } + + const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit])); + const presetIdSet = new Set(preset.selectedUnitIds); + const formationAssignments = selectedUnitIds.reduce((assignments, unitId) => { + const unit = rosterById.get(unitId); + if (!unit) { + return assignments; + } + assignments[unitId] = presetIdSet.has(unitId) && preset.formationAssignments[unitId] + ? preset.formationAssignments[unitId] + : this.sortieFormationRole(unit, scenario); + return assignments; + }, {}); + const selectedIdSet = new Set(selectedUnitIds); + const itemAssignments = Object.entries(this.cloneSortieItemAssignments(this.sortieItemAssignments)) + .reduce((assignments, [unitId, stocks]) => { + if (selectedIdSet.has(unitId)) { + assignments[unitId] = { ...stocks }; + } + return assignments; + }, {}); + const omittedUnitNames = preset.selectedUnitIds + .filter((unitId) => !selectedIdSet.has(unitId)) + .map((unitId) => this.unitName(unitId)); + return { + presetId, + selectedUnitIds, + formationAssignments: normalizeSortieFormationAssignments(formationAssignments, selectedIdSet), + itemAssignments, + omittedUnitNames + }; + } + + private sortiePresetProjectionMatchesCurrent(projection: SortiePresetProjection) { + if (JSON.stringify(projection.selectedUnitIds) !== JSON.stringify(this.selectedSortieUnitIds)) { + return false; + } + const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit])); + return projection.selectedUnitIds.every((unitId) => { + const unit = rosterById.get(unitId); + return Boolean(unit && projection.formationAssignments[unitId] === this.sortieFormationRole(unit)); + }); + } + + private sortiePresetScore(projection: SortiePresetProjection, scenario = this.nextSortieScenario()) { + const selected = new Set(projection.selectedUnitIds); + const units = this.sortieRosterUnits().filter((unit) => selected.has(unit.id)); + const rule = this.nextSortieRule(scenario); + const recommendedUnits = rule.recommended.filter((entry) => selected.has(entry.unitId)).length; + const recommendedClasses = (rule.recommendedClasses ?? []).filter((entry) => + units.some((unit) => entry.classKeys.includes(unit.classKey)) + ).length; + const snapshot = this.sortieSynergySnapshot(projection.selectedUnitIds, scenario, projection.formationAssignments); + const attack = units.reduce((sum, unit) => sum + unit.attack + this.equipmentAttackBonus(unit), 0); + return [ + recommendedUnits, + recommendedClasses, + snapshot.coveredRoleCount, + snapshot.terrainScore, + snapshot.activeBondCount, + attack + ]; + } + + private recommendedSortiePresetId() { + const candidates = campaignSortiePresetIds + .map((presetId) => ({ presetId, projection: this.sortiePresetProjection(presetId) })) + .filter((candidate): candidate is { presetId: CampaignSortiePresetId; projection: SortiePresetProjection } => Boolean(candidate.projection)); + if (candidates.length === 0) { + return undefined; + } + return candidates.reduce((best, candidate) => { + const bestScore = this.sortiePresetScore(best.projection); + const candidateScore = this.sortiePresetScore(candidate.projection); + const better = candidateScore.some((value, index) => value !== bestScore[index] && value > bestScore[index] && + candidateScore.slice(0, index).every((earlier, earlierIndex) => earlier === bestScore[earlierIndex])); + return better ? candidate : best; + }).presetId; + } + + private sortiePresetComparisonMetrics( + projection: SortiePresetProjection, + scenario = this.nextSortieScenario() + ): SortieComparisonMetric[] { + const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit])); + const aggregate = (unitIds: readonly string[], formationAssignments: SortieFormationAssignments) => { + const units = unitIds.map((unitId) => rosterById.get(unitId)).filter((unit): unit is UnitData => Boolean(unit)); + const snapshot = this.sortieSynergySnapshot(unitIds, scenario, formationAssignments); + return { + attack: units.reduce((sum, unit) => sum + unit.attack + this.equipmentAttackBonus(unit), 0), + intelligence: units.reduce((sum, unit) => sum + unit.stats.intelligence, 0), + move: units.length > 0 ? Math.round((units.reduce((sum, unit) => sum + unit.move, 0) / units.length) * 10) / 10 : 0, + terrain: snapshot.terrainScore + }; + }; + const current = aggregate(this.selectedSortieUnitIds, this.sortieFormationAssignments); + const projected = aggregate(projection.selectedUnitIds, projection.formationAssignments); + const values: Omit[] = [ + { key: 'attack', label: '공격 합', current: current.attack, projected: projected.attack }, + { key: 'intelligence', label: '지력 합', current: current.intelligence, projected: projected.intelligence }, + { key: 'move', label: '평균 기동', current: current.move, projected: projected.move }, + { key: 'terrain', label: '지형', current: current.terrain, projected: projected.terrain } + ]; + return values.map((metric) => ({ + ...metric, + delta: Math.round((metric.projected - metric.current) * 10) / 10 + })); + } + private sortieFormationComparisonPreview(): SortieFormationComparisonPreview | undefined { const scenario = this.nextSortieScenario(); const selectedUnitIds = [...this.selectedSortieUnitIds]; const selectedIds = new Set(selectedUnitIds); const current = this.sortieSynergySnapshot(selectedUnitIds, scenario); + const presetId = this.sortieHoveredPresetId ?? this.sortiePinnedPresetId; + if (presetId) { + const projection = this.sortiePresetProjection(presetId, scenario); + if (projection) { + const projected = this.sortieSynergySnapshot( + projection.selectedUnitIds, + scenario, + projection.formationAssignments + ); + const comparison = compareSortieSynergy(current, projected); + const projectedIds = new Set(projection.selectedUnitIds); + const retainedCount = selectedUnitIds.filter((unitId) => projectedIds.has(unitId)).length; + const outgoingCount = selectedUnitIds.length - retainedCount; + const incomingCount = projection.selectedUnitIds.length - retainedCount; + const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit])); + const roleChangeCount = projection.selectedUnitIds.filter((unitId) => { + const unit = rosterById.get(unitId); + return Boolean(unit && selectedIds.has(unitId) && projection.formationAssignments[unitId] !== this.sortieFormationRole(unit, scenario)); + }).length; + const definition = this.sortiePresetDefinition(presetId); + return { + source: this.sortiePinnedPresetId === presetId && !this.sortieHoveredPresetId ? 'pinned-preset' : 'hover-preset', + mode: 'preset', + unitId: presetId, + presetId, + incomingUnitId: null, + incomingUnitName: null, + outgoingUnitId: null, + outgoingUnitName: null, + selectedUnitIds, + projectedSelectedUnitIds: projection.selectedUnitIds, + headline: `유지 ${retainedCount}명 · 교체 ${outgoingCount}OUT/${incomingCount}IN · 역할 ${roleChangeCount}명 변경 · ${incomingCount > 0 ? `신규 ${incomingCount}명 보급 미배정` : '보급 유지'}`, + detail: projection.omittedUnitNames.length > 0 + ? `${definition.label} 저장 명단 중 ${projection.omittedUnitNames.join('·')}은 이번 장면에서 제외됩니다.` + : `${definition.label} 편성책의 명단과 역할을 현재 장면에 맞춰 적용합니다.`, + metrics: this.sortiePresetComparisonMetrics(projection, scenario), + current, + projected, + comparison + }; + } + } + const appliedPreset = this.sortiePresetUndoState; + if (appliedPreset && this.sortiePersistedConfigurationMatches(appliedPreset.applied)) { + const projection = this.sortiePresetProjection(appliedPreset.presetId, scenario); + if (projection) { + return { + source: 'focus', + mode: 'preset', + unitId: appliedPreset.presetId, + presetId: appliedPreset.presetId, + incomingUnitId: null, + incomingUnitName: null, + outgoingUnitId: null, + outgoingUnitName: null, + selectedUnitIds, + projectedSelectedUnitIds: selectedUnitIds, + headline: `${this.sortiePresetDefinition(appliedPreset.presetId).label} 편성책 적용 완료`, + detail: '유지 가능한 보급만 보존했습니다.', + metrics: this.sortiePresetComparisonMetrics(projection, scenario), + current, + projected: current + }; + } + } const focused = this.sortieFocusedUnit(); const hovered = this.sortieHoveredUnitId ? this.sortieRosterUnits().find((unit) => unit.id === this.sortieHoveredUnitId) @@ -14458,6 +14977,164 @@ export class CampScene extends Phaser.Scene { }; } + private toggleSortiePresetBrowser(forceOpen?: boolean) { + const open = forceOpen ?? !this.sortiePresetBrowserOpen; + if (!open) { + this.closeSortiePresetBrowserState(); + } else { + this.sortiePresetBrowserOpen = true; + this.sortieHoveredUnitId = undefined; + this.sortiePinnedSwapCandidateUnitId = undefined; + this.sortieHoveredPresetId = undefined; + this.sortiePresetOverwriteConfirmId = undefined; + } + soundDirector.playSelect(); + this.showSortiePrep(); + } + + private closeSortiePresetBrowserState() { + this.sortiePresetBrowserOpen = false; + this.sortieHoveredPresetId = undefined; + this.sortiePinnedPresetId = undefined; + this.sortiePresetOverwriteConfirmId = undefined; + } + + private pinSortiePreset(presetId: CampaignSortiePresetId) { + const projection = this.sortiePresetProjection(presetId); + const definition = this.sortiePresetDefinition(presetId); + if (!projection) { + this.sortiePinnedPresetId = undefined; + this.refreshCampaignSortieComparisonPanel(); + this.showCampNotice(`${definition.label} 편성책은 이번 장면에 적용할 수 있는 무장이 없습니다.`); + return; + } + if (this.sortiePresetProjectionMatchesCurrent(projection)) { + this.sortiePinnedPresetId = undefined; + this.refreshCampaignSortieComparisonPanel(); + this.showCampNotice(`${definition.label} 편성책이 이미 현재 명단과 역할에 적용되어 있습니다.`); + return; + } + + this.sortiePinnedPresetId = presetId; + this.sortieHoveredPresetId = undefined; + this.sortiePinnedSwapCandidateUnitId = undefined; + this.sortiePresetOverwriteConfirmId = undefined; + soundDirector.playSelect(); + this.showSortiePrep(); + } + + private requestSaveCurrentSortiePreset(presetId: CampaignSortiePresetId) { + const definition = this.sortiePresetDefinition(presetId); + if (this.selectedSortieUnitIds.length === 0) { + this.showCampNotice(`${definition.label} 편성책에 저장할 출전 명단이 없습니다.`); + return; + } + const selectedUnitIds = this.normalizedSortieUnitIds(this.selectedSortieUnitIds); + + const existing = this.sortieFormationPresets()[presetId]; + if (existing && this.sortiePresetOverwriteConfirmId !== presetId) { + this.sortiePresetOverwriteConfirmId = presetId; + this.sortiePinnedPresetId = undefined; + this.sortieHoveredPresetId = undefined; + soundDirector.playSelect(); + this.showSortiePrep(); + this.showCampNotice(`${definition.label} 편성책을 덮어쓰려면 저장 확정을 한 번 더 누르십시오.`); + return; + } + + const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit])); + const formationAssignments = selectedUnitIds.reduce((assignments, unitId) => { + const unit = rosterById.get(unitId); + if (unit) { + assignments[unitId] = this.sortieFormationRole(unit); + } + return assignments; + }, {}); + const campaign = getCampaignState(); + campaign.sortieFormationPresets = { + ...campaign.sortieFormationPresets, + [presetId]: { + selectedUnitIds: [...selectedUnitIds], + formationAssignments: { ...formationAssignments } + } + }; + this.campaign = saveCampaignState(campaign); + this.sortiePresetOverwriteConfirmId = undefined; + this.sortiePinnedPresetId = undefined; + this.sortieHoveredPresetId = undefined; + this.sortiePlanFeedback = `${definition.label} 편성책 저장 · 명단 ${selectedUnitIds.length}명과 역할 기록`; + soundDirector.playSelect(); + this.showSortiePrep(); + this.showCampNotice(`${definition.label} 편성책 저장 완료 · 보급은 포함하지 않았습니다.`); + } + + private confirmPinnedSortiePreset(presetId: CampaignSortiePresetId) { + const preview = this.sortieFormationComparisonPreview(); + if (preview?.source !== 'pinned-preset' || preview.presetId !== presetId) { + this.sortiePinnedPresetId = undefined; + this.refreshCampaignSortieComparisonPanel(); + this.showCampNotice('고정된 편성책 비교안이 없습니다. 비교 버튼을 먼저 누르십시오.'); + return; + } + + const projection = this.sortiePresetProjection(presetId); + if (!projection || JSON.stringify(projection.selectedUnitIds) !== JSON.stringify(preview.projectedSelectedUnitIds)) { + this.sortiePinnedPresetId = undefined; + this.refreshCampaignSortieComparisonPanel(); + this.showCampNotice('편성 조건이 달라졌습니다. 편성책을 다시 비교하십시오.'); + return; + } + + const before = this.captureSortieConfiguration(); + this.sortiePresetUndoState = undefined; + this.sortieSwapUndoState = undefined; + this.selectedSortieUnitIds = [...projection.selectedUnitIds]; + this.sortieFormationAssignments = { ...projection.formationAssignments }; + this.sortieItemAssignments = this.cloneSortieItemAssignments(projection.itemAssignments); + this.sortieFocusedUnitId = projection.selectedUnitIds.includes(this.sortieFocusedUnitId) + ? this.sortieFocusedUnitId + : projection.selectedUnitIds[0]; + this.sortieRosterScroll = 0; + const definition = this.sortiePresetDefinition(presetId); + this.sortiePlanFeedback = `${definition.label} 편성책 적용 완료 · 장비·보급 재점검`; + this.persistSortieSelection(); + this.sortiePresetUndoState = { + presetId, + before, + applied: this.captureSortieConfiguration() + }; + this.closeSortiePresetBrowserState(); + soundDirector.playSelect(); + this.showSortiePrep(); + this.showCampNotice(`${definition.label} 편성책 적용 완료 · 공통 무장의 보급만 유지했습니다.`); + } + + private undoLastSortiePreset() { + const undo = this.sortiePresetUndoState; + if (!undo || !this.sortiePersistedConfigurationMatches(undo.applied)) { + this.sortiePresetUndoState = undefined; + this.refreshCampaignSortieComparisonPanel(); + this.showCampNotice('이후 편성이 변경되어 직전 편성책 적용을 되돌릴 수 없습니다.'); + return; + } + + this.sortiePresetUndoState = 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; + const definition = this.sortiePresetDefinition(undo.presetId); + this.persistSortieSelection(); + this.closeSortiePresetBrowserState(); + soundDirector.playSelect(); + this.showSortiePrep(); + this.showCampNotice(`${definition.label} 편성책 적용을 되돌렸습니다.`); + } + private sortieSwapProjection( outgoingUnitId: string, incomingUnitId: string, @@ -14615,6 +15292,7 @@ export class CampScene extends Phaser.Scene { before, applied: this.captureSortieConfiguration() }; + this.closeSortiePresetBrowserState(); soundDirector.playSelect(); this.showSortiePrep(); this.showCampNotice(`${projection.incomingUnit.name} 교체 완료 · 장비·보급은 자동 이전하지 않았습니다.`); @@ -14639,6 +15317,7 @@ export class CampScene extends Phaser.Scene { this.sortieHoveredUnitId = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; this.persistSortieSelection(); + this.closeSortiePresetBrowserState(); soundDirector.playSelect(); this.showSortiePrep(); this.showCampNotice(`${undo.outgoingUnitName} → ${undo.incomingUnitName} 교체를 되돌렸습니다.`); @@ -15188,8 +15867,12 @@ export class CampScene extends Phaser.Scene { }; } - private sortieFormationRole(unit: UnitData, scenario = this.nextSortieScenario()): SortieFormationRole { - return this.sortieFormationAssignments[unit.id] ?? this.sortieRecommendation(unit.id, scenario)?.role ?? this.defaultSortieFormationRole(unit); + private sortieFormationRole( + unit: UnitData, + scenario = this.nextSortieScenario(), + formationAssignments: SortieFormationAssignments = this.sortieFormationAssignments + ): SortieFormationRole { + return formationAssignments[unit.id] ?? this.sortieRecommendation(unit.id, scenario)?.role ?? this.defaultSortieFormationRole(unit); } private defaultSortieFormationRole(unit: UnitData): SortieFormationRole { @@ -15782,8 +16465,11 @@ export class CampScene extends Phaser.Scene { this.sortieHoveredUnitId = undefined; if (clearPinnedSwap) { this.sortiePinnedSwapCandidateUnitId = undefined; + this.closeSortiePresetBrowserState(); } this.sortieComparisonPanelView = undefined; + this.sortiePresetBrowserView = undefined; + this.sortiePresetToggleButton = undefined; } private trackSortie(object: T) { @@ -17303,6 +17989,10 @@ export class CampScene extends Phaser.Scene { private persistSortieSelection() { this.sortieSwapUndoState = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; + this.sortiePresetUndoState = undefined; + this.sortieHoveredPresetId = undefined; + this.sortiePinnedPresetId = undefined; + this.sortiePresetOverwriteConfirmId = undefined; const campaign = this.campaign ?? getCampaignState(); this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(this.sortieFormationAssignments); this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.sortieItemAssignments); @@ -17540,6 +18230,14 @@ export class CampScene extends Phaser.Scene { return [...this.sortieDeploymentPlanForScenario(scenario).values()]; } + private sortieObjectBoundsDebug(object?: Phaser.GameObjects.Rectangle) { + if (!object?.active) { + return null; + } + const bounds = object.getBounds(); + return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; + } + getDebugState() { const sortieChecklist = this.sortieChecklist(); const sortieScenario = this.nextSortieScenario(); @@ -17556,6 +18254,8 @@ export class CampScene extends Phaser.Scene { const portraitRosterScroll = Phaser.Math.Clamp(this.sortieRosterScroll, 0, portraitRosterMaxScroll); const sortieComparisonPreview = this.sortieFormationComparisonPreview(); const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview); + const recommendedPresetId = this.recommendedSortiePresetId(); + const sortieFormationPresets = this.sortieFormationPresets(); return { scene: this.scene.key, activeTab: this.activeTab, @@ -17608,6 +18308,53 @@ export class CampScene extends Phaser.Scene { sortieFocusedUnitId: this.sortieFocusedUnitId, sortieHoveredUnitId: this.sortieHoveredUnitId ?? null, sortiePinnedSwapCandidateUnitId: this.sortiePinnedSwapCandidateUnitId ?? null, + sortieFormationPresets: Object.fromEntries( + Object.entries(sortieFormationPresets).map(([presetId, preset]) => [ + presetId, + preset + ? { + selectedUnitIds: [...preset.selectedUnitIds], + formationAssignments: { ...preset.formationAssignments } + } + : null + ]) + ), + sortiePresetBrowser: { + open: this.sortiePresetBrowserOpen, + hoveredPresetId: this.sortieHoveredPresetId ?? null, + pinnedPresetId: this.sortiePinnedPresetId ?? null, + overwriteConfirmId: this.sortiePresetOverwriteConfirmId ?? null, + recommendedPresetId: recommendedPresetId ?? null, + toggleButtonBounds: this.sortieObjectBoundsDebug(this.sortiePresetToggleButton), + closeButtonBounds: this.sortieObjectBoundsDebug(this.sortiePresetBrowserView?.closeButton), + cards: sortiePresetDefinitions.map((definition) => { + const preset = sortieFormationPresets[definition.id]; + const projection = this.sortiePresetProjection(definition.id); + const snapshot = projection + ? this.sortieSynergySnapshot(projection.selectedUnitIds, sortieScenario, projection.formationAssignments) + : undefined; + const view = this.sortiePresetBrowserView?.cards[definition.id]; + return { + id: definition.id, + label: definition.label, + saved: Boolean(preset), + applicable: Boolean(projection), + recommended: recommendedPresetId === definition.id, + current: Boolean(projection && this.sortiePresetProjectionMatchesCurrent(projection)), + selectedUnitIds: preset ? [...preset.selectedUnitIds] : [], + formationAssignments: preset ? { ...preset.formationAssignments } : {}, + projectedSelectedUnitIds: projection ? [...projection.selectedUnitIds] : [], + projectedFormationAssignments: projection ? { ...projection.formationAssignments } : {}, + projectedItemAssignments: projection ? this.cloneSortieItemAssignments(projection.itemAssignments) : {}, + omittedUnitNames: projection ? [...projection.omittedUnitNames] : [], + projectedRoleCoverage: snapshot?.coveredRoleCount ?? 0, + projectedBondCount: snapshot?.activeBondCount ?? 0, + backgroundBounds: this.sortieObjectBoundsDebug(view?.background), + compareButtonBounds: this.sortieObjectBoundsDebug(view?.compareButton), + saveButtonBounds: this.sortieObjectBoundsDebug(view?.saveButton) + }; + }) + }, sortieComparisonPreview: sortieComparisonPreview ?? null, sortieComparisonAction: sortieComparisonAction ? { ...sortieComparisonAction, enabled: true } : null, sortieSwapUndo: this.sortieSwapUndoState @@ -17617,6 +18364,12 @@ export class CampScene extends Phaser.Scene { incomingUnitId: this.sortieSwapUndoState.incomingUnitId } : null, + sortiePresetUndo: this.sortiePresetUndoState + ? { + available: sortieComparisonAction?.kind === 'undo-preset', + presetId: this.sortiePresetUndoState.presetId + } + : null, sortieFocusedUnit: this.sortieFocusedUnitSummary(), sortiePlanFeedback: this.sortiePlanFeedback, sortieChecklist, diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 15b66c3..a3325cb 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -325,6 +325,16 @@ export type CampaignBattleSettlement = { export type CampaignSortieItemAssignments = Record>; +export const campaignSortiePresetIds = ['elite', 'mobile', 'siege'] as const; +export type CampaignSortiePresetId = (typeof campaignSortiePresetIds)[number]; + +export type CampaignSortiePreset = { + selectedUnitIds: string[]; + formationAssignments: SortieFormationAssignments; +}; + +export type CampaignSortieFormationPresets = Partial>; + export type CampaignState = { version: 1; updatedAt: string; @@ -337,6 +347,7 @@ export type CampaignState = { selectedSortieUnitIds: string[]; sortieFormationAssignments: SortieFormationAssignments; sortieItemAssignments: CampaignSortieItemAssignments; + sortieFormationPresets: CampaignSortieFormationPresets; reserveTrainingFocus: CampaignReserveTrainingFocusId; reserveTrainingAssignments: CampaignReserveTrainingAssignments; completedCampDialogues: string[]; @@ -538,6 +549,7 @@ export function createInitialCampaignState(): CampaignState { selectedSortieUnitIds: [], sortieFormationAssignments: {}, sortieItemAssignments: {}, + sortieFormationPresets: {}, reserveTrainingFocus: defaultCampaignReserveTrainingFocusId, reserveTrainingAssignments: {}, completedCampDialogues: [], @@ -926,6 +938,7 @@ function normalizeCampaignState(state: Partial): CampaignState { .filter((unitId) => !sortieUnitFilter || sortieUnitFilter.has(unitId)); normalized.sortieFormationAssignments = normalizeSortieFormationAssignments(recordOrEmpty(normalized.sortieFormationAssignments), sortieUnitFilter); normalized.sortieItemAssignments = normalizeCampaignSortieItemAssignments(recordOrEmpty(normalized.sortieItemAssignments), sortieUnitFilter); + normalized.sortieFormationPresets = normalizeCampaignSortieFormationPresets(normalized.sortieFormationPresets, sortieUnitFilter); normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus); normalized.reserveTrainingAssignments = normalizeCampaignReserveTrainingAssignments(recordOrEmpty(normalized.reserveTrainingAssignments), sortieUnitFilter); normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory); @@ -1316,6 +1329,32 @@ export function normalizeCampaignSortieItemAssignments(assignments?: Record) { + const source = recordOrEmpty(value); + return campaignSortiePresetIds.reduce((presets, presetId) => { + const candidate = source[presetId]; + if (!isPlainObject(candidate)) { + return presets; + } + + const selectedUnitIds = uniqueStrings(candidate.selectedUnitIds) + .filter((unitId) => !allowedUnitIds || allowedUnitIds.has(unitId)) + .slice(0, maxCampaignSortieAssignmentUnits); + if (selectedUnitIds.length === 0) { + return presets; + } + + presets[presetId] = { + selectedUnitIds, + formationAssignments: normalizeSortieFormationAssignments( + recordOrEmpty(candidate.formationAssignments), + new Set(selectedUnitIds) + ) + }; + return presets; + }, {}); +} + function normalizeReserveTrainingFocusId(focusId?: string): CampaignReserveTrainingFocusId { return isReserveTrainingFocusId(focusId) ? focusId as CampaignReserveTrainingFocusId