diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 1e1a990..b43c7d6 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -993,6 +993,273 @@ try { firstSortieSynergy?.firstPursuitTrinityConfigured === true, `Expected first sortie to preview three bonds, the strongest effect, and trinity: ${JSON.stringify(firstSortieSynergy)}` ); + + const expectedFirstSortiePursuitPairs = [ + { + id: 'liu-bei__guan-yu', + unitIds: ['liu-bei', 'guan-yu'], + unitNames: ['유비', '관우'], + title: '도원결의', + level: 72, + chainRate: 18, + damageBonus: 9 + }, + { + id: 'liu-bei__zhang-fei', + unitIds: ['liu-bei', 'zhang-fei'], + unitNames: ['유비', '장비'], + title: '의형제', + level: 68, + chainRate: 8, + damageBonus: 9 + }, + { + id: 'guan-yu__zhang-fei', + unitIds: ['guan-yu', 'zhang-fei'], + unitNames: ['관우', '장비'], + title: '맹장 공명', + level: 64, + chainRate: 8, + damageBonus: 9 + } + ]; + const firstSortiePursuit = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); + const logicalViewportBounds = { x: 0, y: 0, width: 1280, height: 720 }; + assert( + sameJsonValue(firstSortiePursuit?.activePairs, expectedFirstSortiePursuitPairs) && + firstSortiePursuit?.selectableOpportunities?.length === 0, + `Expected the selected founding trio to expose ordered 18/8/8 resonance-pursuit pairs without reserve opportunities: ${JSON.stringify(firstSortiePursuit)}` + ); + assert( + firstSortiePursuit?.summaryText === '조합 시너지 · 추격 후보 3조 · 역할 3/3' && + firstSortiePursuit?.ruleText === '같은 적을 먼저 친 공명 장수가 지원 거리 안에서 표시 확률로 추격합니다.' && + isFiniteBounds(firstSortiePursuit.panelBounds) && + isFiniteBounds(firstSortiePursuit.ruleBounds) && + boundsInside(firstSortiePursuit.panelBounds, logicalViewportBounds) && + boundsInside(firstSortiePursuit.ruleBounds, firstSortiePursuit.panelBounds) && + sameJsonValue(firstSortiePursuit.rows?.map((row) => ({ state: row.state, text: row.text })), [ + { state: 'candidate', text: '18% 유비↔관우' }, + { state: 'candidate', text: '8% 유비↔장비' }, + { state: 'candidate', text: '8% 관우↔장비' } + ]) && + firstSortiePursuit.rows.every( + (row) => + isFiniteBounds(row.bounds) && + isFiniteBounds(row.textBounds) && + boundsInside(row.bounds, firstSortiePursuit.panelBounds) && + boundsInside(row.textBounds, row.bounds, 2) + ), + `Expected all three pursuit chips and their labels to remain visible inside the first-sortie panel: ${JSON.stringify(firstSortiePursuit)}` + ); + + const firstSortiePursuitSaveBefore = await readCampaignSave(page); + const firstSortiePursuitMutation = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + if ( + !scene || + typeof scene.persistSortieSelection !== 'function' || + typeof scene.showSortiePrep !== 'function' + ) { + return { ready: false }; + } + + const before = { + selectedUnitIds: [...scene.selectedSortieUnitIds], + formationAssignments: { ...scene.sortieFormationAssignments }, + itemAssignments: structuredClone(scene.sortieItemAssignments ?? {}), + focusedUnitId: scene.sortieFocusedUnitId ?? null, + planFeedback: scene.sortiePlanFeedback, + rosterScroll: scene.sortieRosterScroll + }; + scene.selectedSortieUnitIds = scene.selectedSortieUnitIds.filter((unitId) => unitId !== 'guan-yu'); + const formationAssignments = { ...scene.sortieFormationAssignments }; + delete formationAssignments['guan-yu']; + scene.sortieFormationAssignments = formationAssignments; + const itemAssignments = structuredClone(scene.sortieItemAssignments ?? {}); + delete itemAssignments['guan-yu']; + scene.sortieItemAssignments = itemAssignments; + scene.sortieFocusedUnitId = 'guan-yu'; + const state = window.__HEROS_DEBUG__?.camp(); + return { + ready: true, + before, + after: { + selectedUnitIds: state?.selectedSortieUnitIds, + formationAssignments: state?.sortieFormationAssignments, + itemAssignments: state?.sortieItemAssignments, + focusedUnitId: state?.sortieFocusedUnitId, + pursuit: state?.sortiePursuitPreview, + focusedSynergy: state?.sortieFocusedSynergyPreview + } + }; + }); + const expectedGuanYuPursuitOpportunities = [ + { + ...expectedFirstSortiePursuitPairs[0], + selectedPartnerId: 'liu-bei', + selectedPartnerName: '유비', + candidateUnitId: 'guan-yu', + candidateUnitName: '관우' + }, + { + ...expectedFirstSortiePursuitPairs[2], + selectedPartnerId: 'zhang-fei', + selectedPartnerName: '장비', + candidateUnitId: 'guan-yu', + candidateUnitName: '관우' + } + ]; + assert( + firstSortiePursuitMutation.ready === true && + !firstSortiePursuitMutation.after?.selectedUnitIds?.includes('guan-yu') && + firstSortiePursuitMutation.after?.focusedUnitId === 'guan-yu' && + !Object.prototype.hasOwnProperty.call(firstSortiePursuitMutation.after?.formationAssignments ?? {}, 'guan-yu') && + !Object.prototype.hasOwnProperty.call(firstSortiePursuitMutation.after?.itemAssignments ?? {}, 'guan-yu'), + `Expected the RC fixture to bench Guan Yu and clear only his live role and supply assignments: ${JSON.stringify(firstSortiePursuitMutation)}` + ); + assert( + sameJsonValue(firstSortiePursuitMutation.after?.pursuit?.activePairs, [expectedFirstSortiePursuitPairs[1]]) && + sameJsonValue( + firstSortiePursuitMutation.after?.pursuit?.selectableOpportunities, + expectedGuanYuPursuitOpportunities + ), + `Expected benching Guan Yu to leave one active 8% pursuit and expose his 18%/8% partner opportunities: ${JSON.stringify(firstSortiePursuitMutation.after?.pursuit)}` + ); + assert( + firstSortiePursuitMutation.after?.focusedSynergy?.mode === 'add' && + firstSortiePursuitMutation.after.focusedSynergy.comparison?.activeBondDelta === 2 && + sameJsonValue( + firstSortiePursuitMutation.after.focusedSynergy.comparison.gainedBonds?.map((bond) => bond.id), + ['liu-bei__guan-yu', 'guan-yu__zhang-fei'] + ) && + firstSortiePursuitMutation.after.focusedSynergy.comparison.lostBonds?.length === 0 && + sameJsonValue( + firstSortiePursuitMutation.after.focusedSynergy.projected?.activeBonds?.map((bond) => bond.id), + expectedFirstSortiePursuitPairs.map((pair) => pair.id) + ), + `Expected Guan Yu's focused add projection to restore both pursuit bonds and the full trio: ${JSON.stringify(firstSortiePursuitMutation.after?.focusedSynergy)}` + ); + + const firstSortiePursuitRemoveProjection = await page.evaluate((before) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + if (!scene || typeof scene.persistSortieSelection !== 'function' || typeof scene.showSortiePrep !== 'function') { + return { ready: false }; + } + const scenario = scene.nextSortieScenario(); + const current = scene.sortieSynergySnapshot(before.selectedUnitIds, scenario, before.formationAssignments); + const projectedUnitIds = before.selectedUnitIds.filter((unitId) => unitId !== 'guan-yu'); + const projectedFormationAssignments = { ...before.formationAssignments }; + delete projectedFormationAssignments['guan-yu']; + const projected = scene.sortieSynergySnapshot(projectedUnitIds, scenario, projectedFormationAssignments); + const pursuitChanges = scene.changedSortiePursuitPairs(current, projected); + + scene.selectedSortieUnitIds = [...before.selectedUnitIds]; + scene.sortieFormationAssignments = { ...before.formationAssignments }; + scene.sortieItemAssignments = structuredClone(before.itemAssignments); + scene.sortieFocusedUnitId = before.focusedUnitId ?? undefined; + scene.sortiePlanFeedback = before.planFeedback; + scene.sortieRosterScroll = before.rosterScroll; + scene.persistSortieSelection(); + scene.showSortiePrep(); + const state = window.__HEROS_DEBUG__?.camp(); + return { + ready: true, + selectedUnitIds: state?.selectedSortieUnitIds, + pursuit: state?.sortiePursuitPreview, + removeProjection: { + currentPairIds: pursuitChanges.currentPairs.map((pair) => pair.id), + projectedPairIds: pursuitChanges.projectedPairs.map((pair) => pair.id), + gainedPairIds: pursuitChanges.gainedPairs.map((pair) => pair.id), + lostPairIds: pursuitChanges.lostPairs.map((pair) => pair.id), + activePairDelta: pursuitChanges.projectedPairs.length - pursuitChanges.currentPairs.length + } + }; + }, firstSortiePursuitMutation.before); + assert( + firstSortiePursuitRemoveProjection.ready === true && + sameJsonValue(firstSortiePursuitRemoveProjection.selectedUnitIds, firstSortiePursuitMutation.before?.selectedUnitIds) && + sameJsonValue(firstSortiePursuitRemoveProjection.pursuit?.activePairs, expectedFirstSortiePursuitPairs) && + firstSortiePursuitRemoveProjection.pursuit?.selectableOpportunities?.length === 0 && + sameJsonValue( + firstSortiePursuitRemoveProjection.removeProjection?.currentPairIds, + expectedFirstSortiePursuitPairs.map((pair) => pair.id) + ) && + sameJsonValue(firstSortiePursuitRemoveProjection.removeProjection?.projectedPairIds, ['liu-bei__zhang-fei']) && + firstSortiePursuitRemoveProjection.removeProjection?.activePairDelta === -2 && + sameJsonValue( + firstSortiePursuitRemoveProjection.removeProjection?.lostPairIds, + ['liu-bei__guan-yu', 'guan-yu__zhang-fei'] + ) && + firstSortiePursuitRemoveProjection.removeProjection?.gainedPairIds?.length === 0, + `Expected the restored trio to preview exactly the two pursuit pairs lost by removing Guan Yu: ${JSON.stringify(firstSortiePursuitRemoveProjection)}` + ); + + const firstSortiePursuitRestored = await page.evaluate((before) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + if (!scene || typeof scene.persistSortieSelection !== 'function' || typeof scene.showSortiePrep !== 'function') { + return { ready: false }; + } + scene.selectedSortieUnitIds = [...before.selectedUnitIds]; + scene.sortieFormationAssignments = { ...before.formationAssignments }; + scene.sortieItemAssignments = structuredClone(before.itemAssignments); + scene.sortieFocusedUnitId = before.focusedUnitId ?? undefined; + scene.sortiePlanFeedback = before.planFeedback; + scene.sortieRosterScroll = before.rosterScroll; + scene.persistSortieSelection(); + scene.showSortiePrep(); + const state = window.__HEROS_DEBUG__?.camp(); + return { + ready: true, + selectedUnitIds: state?.selectedSortieUnitIds, + formationAssignments: state?.sortieFormationAssignments, + itemAssignments: state?.sortieItemAssignments, + focusedUnitId: state?.sortieFocusedUnitId ?? null, + planFeedback: state?.sortiePlanFeedback, + rosterScroll: scene.sortieRosterScroll, + deploymentPreview: state?.sortieDeploymentPreview, + pursuit: state?.sortiePursuitPreview + }; + }, firstSortiePursuitMutation.before); + const firstSortiePursuitSaveRestored = await readCampaignSave(page); + assert( + firstSortiePursuitRestored.ready === true && + sameJsonValue(firstSortiePursuitRestored.selectedUnitIds, firstSortiePursuitMutation.before?.selectedUnitIds) && + sameJsonValue(firstSortiePursuitRestored.formationAssignments, firstSortiePursuitMutation.before?.formationAssignments) && + sameJsonValue(firstSortiePursuitRestored.itemAssignments, firstSortiePursuitMutation.before?.itemAssignments) && + firstSortiePursuitRestored.focusedUnitId === firstSortiePursuitMutation.before?.focusedUnitId && + firstSortiePursuitRestored.planFeedback === firstSortiePursuitMutation.before?.planFeedback && + firstSortiePursuitRestored.rosterScroll === firstSortiePursuitMutation.before?.rosterScroll && + sameJsonValue(firstSortiePursuitRestored.deploymentPreview, firstCampDeploymentPreview) && + sameJsonValue(firstSortiePursuitRestored.pursuit?.activePairs, expectedFirstSortiePursuitPairs), + `Expected the pursuit RC fixture to restore the exact live formation, roles, supplies, focus, feedback, scroll, and deployment: ${JSON.stringify(firstSortiePursuitRestored)}` + ); + assert( + sameJsonValue( + firstSortiePursuitSaveRestored.current?.selectedSortieUnitIds, + firstSortiePursuitSaveBefore.current?.selectedSortieUnitIds + ) && + sameJsonValue( + firstSortiePursuitSaveRestored.current?.sortieFormationAssignments, + firstSortiePursuitSaveBefore.current?.sortieFormationAssignments + ) && + sameJsonValue( + firstSortiePursuitSaveRestored.current?.sortieItemAssignments, + firstSortiePursuitSaveBefore.current?.sortieItemAssignments + ) && + sameJsonValue( + firstSortiePursuitSaveRestored.slot1?.selectedSortieUnitIds, + firstSortiePursuitSaveBefore.slot1?.selectedSortieUnitIds + ) && + sameJsonValue( + firstSortiePursuitSaveRestored.slot1?.sortieFormationAssignments, + firstSortiePursuitSaveBefore.slot1?.sortieFormationAssignments + ) && + sameJsonValue( + firstSortiePursuitSaveRestored.slot1?.sortieItemAssignments, + firstSortiePursuitSaveBefore.slot1?.sortieItemAssignments + ), + `Expected the pursuit RC fixture to restore current and slot-1 sortie saves before launch: ${JSON.stringify(firstSortiePursuitSaveRestored)}` + ); await advanceSortiePrepStep(page, 'loadout'); await page.mouse.click(1116, 656); await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit'); @@ -1321,9 +1588,10 @@ try { midFormationSwapPreviewProbe.panel.title === '가상 교체 · 마대 → 관우' && midFormationSwapPreviewProbe.panel.headline?.includes('OUT 마대') && midFormationSwapPreviewProbe.panel.headline?.includes('IN 관우') && + midFormationSwapPreviewProbe.panel.impact?.includes('추격') && midFormationSwapPreviewProbe.panel.impact?.includes('공백 돌파') && - midFormationSwapPreviewProbe.panel.detail?.includes('+맥성 북문') && - midFormationSwapPreviewProbe.panel.detail?.includes('-산길과 말발굽') && + midFormationSwapPreviewProbe.panel.detail?.includes('추격 해제') && + midFormationSwapPreviewProbe.panel.detail?.includes('8%') && midFormationSwapPreviewProbe.panel.detail?.includes('실제 편성 미변경'), `Expected the visible comparison panel to render the swap preview: ${JSON.stringify(midFormationSwapPreviewProbe.panel)}` ); diff --git a/scripts/verify-sortie-synergy.mjs b/scripts/verify-sortie-synergy.mjs index 1f34e32..e3ead6b 100644 --- a/scripts/verify-sortie-synergy.mjs +++ b/scripts/verify-sortie-synergy.mjs @@ -9,6 +9,7 @@ const server = await createServer({ try { const { compareSortieSynergy, + evaluateSortiePursuitPairs, evaluateSortieSynergy, firstPursuitRolePreviewSummaries, firstPursuitSynergyBattleId, @@ -121,6 +122,70 @@ try { ); assert(JSON.stringify({ members, bonds }) === frozenInputs, 'Expected synergy evaluation not to mutate its inputs.'); + const pursuitMembers = [ + { id: 'selected-a', name: 'Selected A', role: 'front', terrainScore: 100 }, + { id: 'selected-b', name: 'Selected B', role: 'flank', terrainScore: 100 }, + { id: 'candidate-70', name: 'Candidate 70', role: 'support', terrainScore: 100 }, + { id: 'candidate-50', name: 'Candidate 50', role: 'support', terrainScore: 100 }, + { id: 'candidate-49', name: 'Candidate 49', role: 'support', terrainScore: 100 } + ]; + const pursuitBonds = [ + { id: 'active-70-zeta', title: 'Active Tie', unitIds: ['selected-a', 'selected-b'], level: 70 }, + { id: 'active-70-alpha', title: 'Active Tie', unitIds: ['selected-b', 'selected-a'], level: 70 }, + { id: 'active-50', title: 'Active 50', unitIds: ['selected-a', 'selected-b'], level: 50 }, + { id: 'opportunity-70-zeta', title: 'Zeta Opportunity', unitIds: ['selected-a', 'candidate-70'], level: 70 }, + { id: 'opportunity-70-alpha', title: 'Alpha Opportunity', unitIds: ['candidate-70', 'selected-a'], level: 70 }, + { id: 'opportunity-50-duplicate', title: 'Opportunity 50 Duplicate', unitIds: ['selected-a', 'candidate-70'], level: 50 }, + { id: 'opportunity-50', title: 'Opportunity 50', unitIds: ['selected-b', 'candidate-50'], level: 50 }, + { id: 'opportunity-49', title: 'Opportunity 49', unitIds: ['selected-a', 'candidate-49'], level: 49 } + ]; + const frozenPursuitInputs = JSON.stringify({ pursuitMembers, pursuitBonds }); + const pursuitPairs = evaluateSortiePursuitPairs({ + members: pursuitMembers, + bonds: pursuitBonds, + selectedUnitIds: ['selected-a', 'selected-b', 'selected-a'] + }); + assert( + pursuitPairs.activePairs.map((bond) => `${bond.id}:${bond.chainRate}`).join(',') === 'active-70-alpha:18', + `Expected a selected pair to keep its highest-level bond and resolve level ties by title/id while normalizing duplicate selections: ${JSON.stringify(pursuitPairs.activePairs)}` + ); + assert( + pursuitPairs.selectableOpportunities.map((bond) => `${bond.id}:${bond.chainRate}`).join(',') === 'opportunity-70-alpha:18,opportunity-50:8', + `Expected opportunities to keep one strongest unordered pair, resolve ties deterministically, expose Lv70/Lv50 rates, and hide Lv49: ${JSON.stringify(pursuitPairs.selectableOpportunities)}` + ); + assert( + pursuitPairs.selectableOpportunities[0]?.selectedPartnerId === 'selected-a' && + pursuitPairs.selectableOpportunities[0]?.selectedPartnerName === 'Selected A' && + pursuitPairs.selectableOpportunities[0]?.candidateUnitId === 'candidate-70' && + pursuitPairs.selectableOpportunities[0]?.candidateUnitName === 'Candidate 70', + `Expected pursuit opportunities to identify the selected partner and selectable candidate: ${JSON.stringify(pursuitPairs.selectableOpportunities[0])}` + ); + const unavailablePursuitPairs = evaluateSortiePursuitPairs({ + members: pursuitMembers, + bonds: pursuitBonds, + selectedUnitIds: ['selected-a', 'selected-b'], + selectableUnitIds: ['selected-a', 'selected-b', 'candidate-50', 'candidate-49'] + }); + assert( + unavailablePursuitPairs.selectableOpportunities.length === 1 && + unavailablePursuitPairs.selectableOpportunities[0]?.candidateUnitId === 'candidate-50', + `Expected unavailable candidates and already-selected ids to stay out of pursuit opportunities: ${JSON.stringify(unavailablePursuitPairs.selectableOpportunities)}` + ); + const promotedPursuitPairs = evaluateSortiePursuitPairs({ + members: pursuitMembers, + bonds: pursuitBonds, + selectedUnitIds: ['selected-a', 'selected-b', 'candidate-70'] + }); + assert( + promotedPursuitPairs.activePairs.some((bond) => bond.id === 'opportunity-70-alpha' && bond.chainRate === 18) && + promotedPursuitPairs.selectableOpportunities.every((bond) => bond.candidateUnitId !== 'candidate-70'), + `Expected a selected candidate to move from opportunity to active pair: ${JSON.stringify(promotedPursuitPairs)}` + ); + assert( + JSON.stringify({ pursuitMembers, pursuitBonds }) === frozenPursuitInputs, + 'Expected pursuit-pair evaluation not to mutate its inputs.' + ); + const orderPerformance = { selectedUnitIds: ['front-unit', 'flank-unit', 'support-unit'], formationAssignments: { diff --git a/src/game/data/sortieSynergy.ts b/src/game/data/sortieSynergy.ts index 66bf049..c032fa2 100644 --- a/src/game/data/sortieSynergy.ts +++ b/src/game/data/sortieSynergy.ts @@ -60,6 +60,36 @@ export type SortieSynergyComparison = { lostRoles: (typeof coreSortieSynergyRoles)[number][]; }; +export type SortiePursuitOpportunity = SortieSynergyActiveBond & { + selectedPartnerId: string; + selectedPartnerName: string; + candidateUnitId: string; + candidateUnitName: string; +}; + +export type SortiePursuitPairs = { + activePairs: SortieSynergyActiveBond[]; + selectableOpportunities: SortiePursuitOpportunity[]; +}; + +function compareSortieActiveBonds(left: SortieSynergyActiveBond, right: SortieSynergyActiveBond) { + return right.level - left.level || left.title.localeCompare(right.title) || left.id.localeCompare(right.id); +} + +function strongestSortieBondPairs(bonds: readonly T[]) { + const seenPairs = new Set(); + return [...bonds] + .sort(compareSortieActiveBonds) + .filter((bond) => { + const pairKey = [...bond.unitIds].sort().join('\u0000'); + if (seenPairs.has(pairKey)) { + return false; + } + seenPairs.add(pairKey); + return true; + }); +} + export function sortieBondBonusForLevel(level: number) { const normalizedLevel = Math.max(0, Math.floor(Number.isFinite(level) ? level : 0)); return { @@ -115,7 +145,7 @@ export function evaluateSortieSynergy(options: { ...bonus }; }) - .sort((left, right) => right.level - left.level || left.title.localeCompare(right.title) || left.id.localeCompare(right.id)); + .sort(compareSortieActiveBonds); const firstPursuitTrinityConfigured = options.battleId === firstPursuitSynergyBattleId && firstPursuitBrotherUnitIds.every((unitId) => selectedIds.has(unitId)) && coreSortieSynergyRoles.every((role) => firstPursuitBrotherUnitIds.some((unitId) => memberById.get(unitId)?.role === role)); @@ -134,6 +164,48 @@ export function evaluateSortieSynergy(options: { }; } +export function evaluateSortiePursuitPairs(options: { + members: readonly SortieSynergyMember[]; + bonds: readonly SortieSynergyBond[]; + selectedUnitIds: readonly string[]; + selectableUnitIds?: readonly string[]; + battleId?: string; +}): SortiePursuitPairs { + const current = evaluateSortieSynergy(options); + const selectedIds = new Set(current.selectedUnitIds); + const selectableUnitIds = [...new Set(options.selectableUnitIds ?? options.members.map((member) => member.id))] + .filter((unitId) => !selectedIds.has(unitId)); + const selectableOpportunities = strongestSortieBondPairs(selectableUnitIds.flatMap((candidateUnitId) => { + const projected = evaluateSortieSynergy({ + ...options, + selectedUnitIds: [...current.selectedUnitIds, candidateUnitId] + }); + return projected.activeBonds.flatMap((bond) => { + if (bond.chainRate <= 0 || !bond.unitIds.includes(candidateUnitId)) { + return []; + } + const selectedPartnerId = bond.unitIds.find((unitId) => selectedIds.has(unitId)); + if (!selectedPartnerId) { + return []; + } + const selectedPartnerIndex = bond.unitIds.indexOf(selectedPartnerId); + const candidateUnitIndex = bond.unitIds.indexOf(candidateUnitId); + return [{ + ...bond, + selectedPartnerId, + selectedPartnerName: bond.unitNames[selectedPartnerIndex], + candidateUnitId, + candidateUnitName: bond.unitNames[candidateUnitIndex] + }]; + }); + })); + + return { + activePairs: strongestSortieBondPairs(current.activeBonds.filter((bond) => bond.chainRate > 0)), + selectableOpportunities + }; +} + export function compareSortieSynergy(current: SortieSynergySnapshot, projected: SortieSynergySnapshot): SortieSynergyComparison { const currentBondIds = new Set(current.activeBonds.map((bond) => bond.id)); const projectedBondIds = new Set(projected.activeBonds.map((bond) => bond.id)); diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index ef4d684..c23cac6 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -33,8 +33,11 @@ import { import { compareSortieSynergy, coreSortieSynergyRoles, + evaluateSortiePursuitPairs, evaluateSortieSynergy, sortieRoleEffectSummaries, + type SortiePursuitPairs, + type SortieSynergyActiveBond, type SortieSynergyComparison, type SortieSynergySnapshot } from '../data/sortieSynergy'; @@ -407,6 +410,18 @@ type SortieComparisonPanelView = { actionLabel: Phaser.GameObjects.Text; }; +type SortiePursuitPanelView = { + background: Phaser.GameObjects.Rectangle; + summaryText: Phaser.GameObjects.Text; + ruleText: Phaser.GameObjects.Text; + rows: { + state: 'candidate'; + text: string; + background: Phaser.GameObjects.Rectangle; + label: Phaser.GameObjects.Text; + }[]; +}; + type SortieConfigurationSnapshot = { selectedUnitIds: string[]; formationAssignments: SortieFormationAssignments; @@ -11022,6 +11037,7 @@ export class CampScene extends Phaser.Scene { private sortieOrderBrowserView?: SortieOrderBrowserView; private sortieOrderToggleButton?: Phaser.GameObjects.Rectangle; private sortieComparisonPanelView?: SortieComparisonPanelView; + private sortiePursuitPanelView?: SortiePursuitPanelView; private sortieRosterScroll = 0; private sortiePlanFeedback = ''; private sortiePrepStep: SortiePrepStep = 'briefing'; @@ -11043,6 +11059,7 @@ export class CampScene extends Phaser.Scene { this.dialogueObjects = []; this.sortieObjects = []; this.sortieComparisonPanelView = undefined; + this.sortiePursuitPanelView = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; this.sortieSwapUndoState = undefined; this.sortieFormationPanelMode = 'roster'; @@ -13509,6 +13526,7 @@ export class CampScene extends Phaser.Scene { const projected = isUndoAction ? current : preview?.projected ?? current; const showMetricTransition = !isUndoAction && Boolean(preview?.projected); const comparison = isUndoAction ? undefined : preview?.comparison; + const pursuitChanges = this.changedSortiePursuitPairs(current, projected); 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); @@ -13535,13 +13553,13 @@ export class CampScene extends Phaser.Scene { return; } view.title.setText('편성 변화'); - view.summary.setText(`공명 ${current.activeBondCount} · 역할 ${current.coveredRoleCount}/3 · 지형 ${current.terrainGrade}`); + view.summary.setText(`추격 후보 ${pursuitChanges.currentPairs.length}조 · 역할 ${current.coveredRoleCount}/3 · 지형 ${current.terrainGrade}`); view.headline.setText('출전 무장을 클릭해 교체 기준으로 고정하십시오.').setColor('#9fb0bf'); view.metrics.forEach((metric) => { metric.background.setFillStyle(0x111922, 0.9).setStrokeStyle(1, 0x53606c, 0.34); metric.value.setText('-').setColor('#77818c'); }); - view.impact.setText('후보 초상에 마우스를 올리면 역할·공명 변화가 표시됩니다.').setColor('#d4dce6'); + view.impact.setText('후보 초상에 마우스를 올리면 역할·추격 후보 변화가 표시됩니다.').setColor('#d4dce6'); view.detail.setText('미리보기는 실제 출전 명단과 보급을 변경하지 않습니다.').setColor('#9fb0bf'); return; } @@ -13562,7 +13580,7 @@ export class CampScene extends Phaser.Scene { view.title.setText(this.compactText(title, 28)); view.summary.setText( this.compactText( - `공명 ${current.activeBondCount}→${projected.activeBondCount} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 지형 ${projected.terrainGrade}`, + `추격 후보 ${pursuitChanges.currentPairs.length}→${pursuitChanges.projectedPairs.length}조 · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 지형 ${projected.terrainGrade}`, 38 ) ); @@ -13605,7 +13623,7 @@ export class CampScene extends Phaser.Scene { if (action?.kind === 'undo-preset' && this.sortiePresetUndoState) { view.impact - .setText(`현재 역할 ${current.coveredRoleCount}/3 · 공명 ${current.activeBondCount} · 지형 ${current.terrainGrade}`) + .setText(`현재 역할 ${current.coveredRoleCount}/3 · 추격 후보 ${pursuitChanges.currentPairs.length}조 · 지형 ${current.terrainGrade}`) .setColor('#a8ffd0'); view.detail .setText('유지 가능한 보급만 보존했습니다. 직전 적용은 한 번 되돌릴 수 있습니다.') @@ -13615,7 +13633,7 @@ export class CampScene extends Phaser.Scene { if (isUndoAction && this.sortieSwapUndoState) { view.impact - .setText(`현재 역할 ${current.coveredRoleCount}/3 · 공명 ${current.activeBondCount} · 지형 ${current.terrainGrade}`) + .setText(`현재 역할 ${current.coveredRoleCount}/3 · 추격 후보 ${pursuitChanges.currentPairs.length}조 · 지형 ${current.terrainGrade}`) .setColor('#a8ffd0'); view.detail .setText('장비·보급은 자동 이전하지 않았습니다. 직전 교체는 한 번 되돌릴 수 있습니다.') @@ -13640,29 +13658,29 @@ export class CampScene extends Phaser.Scene { gainedRoleLabels.length > 0 ? `보강 ${gainedRoleLabels.join('·')}` : '' ].filter(Boolean); const roleChange = roleChanges.join(' · ') || '역할 유지'; - const bondDelta = comparison?.activeBondDelta ?? 0; - const signedBondDelta = bondDelta > 0 ? `+${bondDelta}` : `${bondDelta}`; + const pursuitDelta = pursuitChanges.projectedPairs.length - pursuitChanges.currentPairs.length; + const signedPursuitDelta = pursuitDelta > 0 ? `+${pursuitDelta}` : `${pursuitDelta}`; view.impact .setText( this.compactText( - `역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 공명 ${current.activeBondCount}→${projected.activeBondCount} (${signedBondDelta}) · ${roleChange}`, + `역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 추격 후보 ${pursuitChanges.currentPairs.length}→${pursuitChanges.projectedPairs.length}조 (${signedPursuitDelta}) · ${roleChange}`, 68 ) ) - .setColor(lostRoleLabels.length > 0 ? '#ff9d7d' : gainedRoleLabels.length > 0 || bondDelta > 0 ? '#a8ffd0' : '#d4dce6'); + .setColor(lostRoleLabels.length > 0 || pursuitDelta < 0 ? '#ff9d7d' : gainedRoleLabels.length > 0 || pursuitDelta > 0 ? '#a8ffd0' : '#d4dce6'); - const gainedBonds = comparison?.gainedBonds.map((bond) => `+${bond.title}`) ?? []; - const lostBonds = comparison?.lostBonds.map((bond) => `-${bond.title}`) ?? []; - const bondLine = [gainedBonds[0], lostBonds[0], ...gainedBonds.slice(1), ...lostBonds.slice(1)] - .filter((bond): bond is string => Boolean(bond)) + const gainedPursuits = pursuitChanges.gainedPairs.map((pair) => `추격 개방 ${this.sortiePursuitPairLabel(pair)}`); + const lostPursuits = pursuitChanges.lostPairs.map((pair) => `추격 해제 ${this.sortiePursuitPairLabel(pair)}`); + const pursuitLine = [gainedPursuits[0], lostPursuits[0], ...gainedPursuits.slice(1), ...lostPursuits.slice(1)] + .filter((entry): entry is string => Boolean(entry)) .slice(0, 2) - .join(' · ') || '공명 변화 없음'; + .join(' · ') || '추격 변화 없음'; const terrainDelta = projected.terrainScore - current.terrainScore; const signedTerrainDelta = terrainDelta > 0 ? `+${terrainDelta}` : `${terrainDelta}`; view.detail .setText( this.compactText( - `${bondLine} · 지형 ${current.terrainGrade} ${current.terrainScore}→${projected.terrainGrade} ${projected.terrainScore} (${signedTerrainDelta}) · 실제 편성 미변경`, + `${pursuitLine} · 지형 ${current.terrainGrade} ${current.terrainScore}→${projected.terrainGrade} ${projected.terrainScore} (${signedTerrainDelta}) · ${roleChange} · 실제 편성 미변경`, 82 ) ) @@ -13863,7 +13881,7 @@ export class CampScene extends Phaser.Scene { lineSpacing: 2 }) ).setDepth(depth + 2); - this.trackSortie(this.add.text(cardX + 14, cardY + 278, this.compactText(this.sortieBondLine(unit), 26), this.textStyle(12, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2); + this.trackSortie(this.add.text(cardX + 14, cardY + 278, this.compactText(this.sortieBondLine(unit), 26), this.textStyle(10, selected ? '#a8ffd0' : '#77818c', true))).setDepth(depth + 2); this.renderFirstSortieInlineButton( required ? '필수 출전' : selected ? '출전 확정' : '대기', @@ -13964,30 +13982,48 @@ export class CampScene extends Phaser.Scene { private renderFirstSortieSynergyPanel(x: number, y: number, width: number, height: number, depth: number) { const synergy = this.sortieSynergySnapshot(); + const pursuit = this.sortiePursuitPairs(); const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.94)); bg.setOrigin(0); bg.setDepth(depth); bg.setStrokeStyle(1, synergy.firstPursuitTrinityConfigured ? palette.green : palette.gold, 0.62); - this.trackSortie( + const summaryText = this.trackSortie( this.add.text( x + 18, - y + 12, - `조합 시너지 · 공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3`, - this.textStyle(17, '#f2e3bf', true) + y + 9, + `조합 시너지 · 추격 후보 ${pursuit.activePairs.length}조 · 역할 ${synergy.coveredRoleCount}/3`, + this.textStyle(15, '#f2e3bf', true) ) - ).setDepth(depth + 1); - this.trackSortie(this.add.text(x + width - 18, y + 15, `지형 ${synergy.terrainGrade}`, this.textStyle(11, '#d8b15f', true))).setOrigin(1, 0).setDepth(depth + 1); - const activeBondLine = synergy.activeBonds.length > 0 - ? `${synergy.activeBonds.slice(0, 2).map((bond) => `${bond.title} Lv${bond.level}`).join(' · ')}${synergy.activeBonds.length > 2 ? ` · 외 ${synergy.activeBonds.length - 2}` : ''}` - : '활성 공명 없음'; - this.trackSortie(this.add.text(x + 18, y + 40, this.compactText(activeBondLine, 38), this.textStyle(12, synergy.activeBonds.length > 0 ? '#a8ffd0' : '#ffdf7b', true))).setDepth(depth + 1); - const strongest = synergy.strongestBond; - const strongestLine = strongest - ? `최강 공명 · 조건 충족 시 피해 +${strongest.damageBonus}% / 연계 ${strongest.chainRate}%` - : '공명 파트너를 함께 편성하면 전투 연계가 열립니다.'; - this.trackSortie( - this.add.text(x + 18, y + 64, this.compactText(strongestLine, 44), this.textStyle(11, strongest ? '#d4dce6' : '#9fb0bf')) - ).setDepth(depth + 1); + ); + summaryText.setDepth(depth + 1); + this.trackSortie(this.add.text(x + width - 18, y + 12, `지형 ${synergy.terrainGrade}`, this.textStyle(10, '#d8b15f', true))).setOrigin(1, 0).setDepth(depth + 1); + + const visiblePairs = pursuit.activePairs.slice(0, 3); + const chipGap = 6; + const availableChipWidth = Math.floor((width - 36 - chipGap * Math.max(0, visiblePairs.length - 1)) / Math.max(1, visiblePairs.length)); + const chipWidth = Math.min(118, availableChipWidth); + const rows: SortiePursuitPanelView['rows'] = visiblePairs.map((pair, index) => { + const chipX = x + 18 + index * (chipWidth + chipGap); + const chipText = `${pair.chainRate}% ${pair.unitNames[0]}↔${pair.unitNames[1]}`; + const strongest = index === 0; + const background = this.trackSortie(this.add.rectangle(chipX, y + 34, chipWidth, 22, strongest ? 0x3b3020 : 0x173329, 0.98)); + background.setOrigin(0); + background.setDepth(depth + 1); + background.setStrokeStyle(1, strongest ? palette.gold : palette.green, strongest ? 0.88 : 0.7); + const label = this.trackSortie( + this.add.text(chipX + chipWidth / 2, y + 45, this.compactText(chipText, 13), this.textStyle(10, strongest ? '#ffdf7b' : '#a8ffd0', true)) + ); + label.setOrigin(0.5); + label.setDepth(depth + 2); + return { state: 'candidate' as const, text: chipText, background, label }; + }); + if (rows.length === 0) { + this.trackSortie(this.add.text(x + 18, y + 38, '추격 후보 없음 · 공명 파트너를 함께 편성하십시오.', this.textStyle(11, '#ffdf7b', true))).setDepth(depth + 1); + } + const ruleText = this.trackSortie( + this.add.text(x + 18, y + 62, '같은 적을 먼저 친 공명 장수가 지원 거리 안에서 표시 확률로 추격합니다.', this.textStyle(9, '#d4dce6', true)) + ); + ruleText.setDepth(depth + 1); const missingRoleLabels: Record<(typeof coreSortieSynergyRoles)[number], string> = { front: '전열', flank: '돌파', @@ -14002,6 +14038,7 @@ export class CampScene extends Phaser.Scene { this.trackSortie( this.add.text(x + 18, y + 88, this.compactText(bottomLine, 47), this.textStyle(11, synergy.firstPursuitTrinityConfigured ? '#a8ffd0' : '#ffdf7b', true)) ).setDepth(depth + 1); + this.sortiePursuitPanelView = { background: bg, summaryText, ruleText, rows }; } private renderFirstSortieLoadoutStep( @@ -14124,6 +14161,7 @@ export class CampScene extends Phaser.Scene { const maxUnits = this.sortieMaxUnits(); const hasBattle = Boolean(this.nextSortieScenario()); const synergy = this.sortieSynergySnapshot(); + const pursuit = this.sortiePursuitPairs(); const selectedOrder = this.currentSortieOrderDefinition(); const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.78)); bg.setOrigin(0); @@ -14145,7 +14183,7 @@ export class CampScene extends Phaser.Scene { x + 44, y + 35, this.compactText( - `${hasBattle ? `군령 ${selectedOrder?.label ?? '미선택'} · ` : ''}공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3${hasBattle ? ` · 지형 ${synergy.terrainGrade}` : ''} · 대기 ${reserveUnits.length}명`, + `${hasBattle ? `군령 ${selectedOrder?.label ?? '미선택'} · 추격 후보 ${pursuit.activePairs.length}조 · ` : ''}공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3${hasBattle ? ` · 지형 ${synergy.terrainGrade}` : ''} · 대기 ${reserveUnits.length}명`, 35 ), this.textStyle(12, reserveUnits.length > 0 ? '#c8d2dd' : '#9fb0bf', true) @@ -14429,6 +14467,7 @@ export class CampScene extends Phaser.Scene { const plan = this.sortiePlanSummary(); const scenario = this.nextSortieScenario(); const hasBattle = Boolean(scenario); + const pursuit = this.sortiePursuitPairs(); const canUsePresetBook = this.usesStagedSortiePrep() && this.sortiePrepStep === 'formation' && !this.isFirstSortiePrepSlice(); const canUseSortieOrder = this.usesStagedSortiePrep() && this.sortiePrepStep === 'formation' && hasBattle; const canRecommend = this.canApplyRecommendedSortiePlan(scenario) && this.sortieFormationPanelMode === 'roster'; @@ -14469,7 +14508,7 @@ export class CampScene extends Phaser.Scene { x + 18, y + 38, hasBattle - ? this.compactText(`군령 ${selectedOrder?.label ?? '미선택'} · ${plan.selectedCount}/${plan.maxCount}명 · 지형 ${plan.terrainGrade} · ${this.sortieRecommendedClassLine()}`, 54) + ? this.compactText(`군령 ${selectedOrder?.label ?? '미선택'} · ${plan.selectedCount}/${plan.maxCount}명 · 추격 후보 ${pursuit.activePairs.length}조 · 지형 ${plan.terrainGrade} · ${this.sortieRecommendedClassLine()}`, 54) : `${plan.selectedCount}/${plan.maxCount}명 · 군영 의정 · ${this.sortieRecommendedClassLine()}`, this.textStyle(11, '#d8b15f', true) ) @@ -14531,6 +14570,18 @@ export class CampScene extends Phaser.Scene { const roleLabel = this.sortieFormationRoleLabel(role, hasBattle); this.trackSortie(this.add.text(slotX + 42, slotY + 4, this.compactText(unit.name, 8), this.textStyle(11, '#f2e3bf', true))).setDepth(depth + 1); this.trackSortie(this.add.text(slotX + 42, slotY + 17, roleLabel, this.textStyle(8, '#9fb0bf', true))).setDepth(depth + 1); + const pursuitPartner = hasBattle ? this.sortiePursuitPartnerForUnit(unit.id) : undefined; + if (pursuitPartner) { + const extra = pursuitPartner.additionalCount > 0 ? ` +${pursuitPartner.additionalCount}` : ''; + this.trackSortie( + this.add.text( + slotX + slotWidth - 7, + slotY + 5, + `↔${pursuitPartner.partnerName} ${pursuitPartner.pair.chainRate}%${extra}`, + this.textStyle(9, '#a8ffd0', true) + ) + ).setOrigin(1, 0).setDepth(depth + 1); + } } else { this.trackSortie(this.add.text(slotX + 28, slotY + 8, hasBattle ? '빈 슬롯' : '빈 동행', this.textStyle(10, '#7f8994', true))).setDepth(depth + 1); } @@ -14863,6 +14914,11 @@ export class CampScene extends Phaser.Scene { } private sortieBondLine(unit: UnitData) { + const activePursuit = this.sortiePursuitPartnerForUnit(unit.id); + if (activePursuit) { + return `추격 후보 ${activePursuit.partnerName} ${activePursuit.pair.chainRate}% · 공명 피해 +${activePursuit.pair.damageBonus}%`; + } + const selected = new Set(this.selectedSortieUnitIds); const activeBonds = this.currentBonds() .filter((bond) => bond.unitIds.includes(unit.id)) @@ -14919,6 +14975,94 @@ export class CampScene extends Phaser.Scene { }); } + private sortiePursuitPairs( + selectedUnitIds: readonly string[] = this.selectedSortieUnitIds, + scenario = this.nextSortieScenario(), + formationAssignments: SortieFormationAssignments = this.sortieFormationAssignments, + selectableUnitIds?: readonly string[] + ): SortiePursuitPairs { + if (!scenario) { + return { activePairs: [], selectableOpportunities: [] }; + } + const rule = this.nextSortieRule(scenario); + const members = this.sortieRosterUnits().filter((unit) => this.sortieUnitAvailability(unit, scenario, rule).available); + const availableIds = new Set(members.map((unit) => unit.id)); + return evaluateSortiePursuitPairs({ + members: members.map((unit) => ({ + id: unit.id, + name: unit.name, + role: this.sortieFormationRole(unit, scenario, formationAssignments), + terrainScore: this.sortieTerrainScore(unit, scenario) + })), + bonds: this.sortieSynergyBonds(scenario), + selectedUnitIds: selectedUnitIds.filter((unitId) => availableIds.has(unitId)), + selectableUnitIds: (selectableUnitIds ?? [...availableIds]).filter((unitId) => availableIds.has(unitId)), + battleId: scenario?.id + }); + } + + private sortiePursuitPairKey(pair: Pick) { + return [...pair.unitIds].sort().join('\u0000'); + } + + private activeSortiePursuitPairs(snapshot: SortieSynergySnapshot) { + const seenPairs = new Set(); + return snapshot.activeBonds.filter((bond) => { + const pairKey = this.sortiePursuitPairKey(bond); + if (bond.chainRate <= 0 || seenPairs.has(pairKey)) { + return false; + } + seenPairs.add(pairKey); + return true; + }); + } + + private sortiePursuitPartnerForUnit(unitId: string, pairs = this.sortiePursuitPairs().activePairs) { + const partners = pairs + .filter((pair) => pair.unitIds.includes(unitId)) + .map((pair) => { + const unitIndex = pair.unitIds.indexOf(unitId); + const partnerIndex = unitIndex === 0 ? 1 : 0; + return { + pair, + partnerId: pair.unitIds[partnerIndex], + partnerName: pair.unitNames[partnerIndex] + }; + }) + .sort((left, right) => + right.pair.chainRate - left.pair.chainRate || + right.pair.level - left.pair.level || + left.partnerName.localeCompare(right.partnerName) + ); + return partners[0] + ? { ...partners[0], additionalCount: Math.max(0, partners.length - 1) } + : undefined; + } + + private sortiePursuitPairLabel(pair: SortieSynergyActiveBond) { + return `${pair.unitNames[0]}↔${pair.unitNames[1]} ${pair.chainRate}%`; + } + + private compareSortiePursuitPairs(left: SortieSynergyActiveBond, right: SortieSynergyActiveBond) { + return right.chainRate - left.chainRate || right.level - left.level || left.title.localeCompare(right.title) || left.id.localeCompare(right.id); + } + + private changedSortiePursuitPairs(current: SortieSynergySnapshot, projected: SortieSynergySnapshot) { + if (!this.nextSortieScenario()) { + return { currentPairs: [], projectedPairs: [], gainedPairs: [], lostPairs: [] }; + } + const currentPairs = [...this.activeSortiePursuitPairs(current)].sort((left, right) => this.compareSortiePursuitPairs(left, right)); + const projectedPairs = [...this.activeSortiePursuitPairs(projected)].sort((left, right) => this.compareSortiePursuitPairs(left, right)); + const currentKeys = new Set(currentPairs.map((pair) => this.sortiePursuitPairKey(pair))); + const projectedKeys = new Set(projectedPairs.map((pair) => this.sortiePursuitPairKey(pair))); + return { + currentPairs, + projectedPairs, + gainedPairs: projectedPairs.filter((pair) => !currentKeys.has(this.sortiePursuitPairKey(pair))), + lostPairs: currentPairs.filter((pair) => !projectedKeys.has(this.sortiePursuitPairKey(pair))) + }; + } + private sortieFormationPresets(): CampaignSortieFormationPresets { return this.campaign?.sortieFormationPresets ?? getCampaignState().sortieFormationPresets; } @@ -15720,6 +15864,7 @@ export class CampScene extends Phaser.Scene { private sortieUnitSynergyPreview(unit: UnitData): SortieUnitSynergyPreview { const scenario = this.nextSortieScenario(); const current = this.sortieSynergySnapshot(this.selectedSortieUnitIds, scenario); + const currentPursuitPairs = scenario ? this.activeSortiePursuitPairs(current) : []; const availability = this.sortieUnitAvailability(unit, scenario); const selected = this.isSortieSelected(unit.id); const role = this.sortieFormationRole(unit, scenario); @@ -15735,37 +15880,84 @@ export class CampScene extends Phaser.Scene { }; } + if (!scenario) { + const relatedBonds = current.activeBonds.filter((bond) => bond.unitIds.includes(unit.id)); + if (selected && this.isRequiredSortieUnit(unit.id)) { + return { + mode: 'current', + headline: `필수 동행 · 공명 ${relatedBonds.length}개 연결 · ${roleLabel} 유지`, + detail: `군영 의정 동행 · 지형 ${current.terrainGrade} ${current.terrainScore}`, + compactLabel: `필수 · 공명 ${relatedBonds.length}`, + current + }; + } + if (!selected && this.selectedSortieUnitIds.length >= this.sortieMaxUnits()) { + return { + mode: 'waiting', + headline: '교대 후보 · 동행 슬롯을 먼저 비우십시오.', + detail: `${roleLabel} 후보 · 군영 의정 동행`, + compactLabel: '교대 · 동행 대기', + current + }; + } + const projectedIds = selected + ? this.selectedSortieUnitIds.filter((unitId) => unitId !== unit.id) + : [...this.selectedSortieUnitIds, unit.id]; + const projected = this.sortieSynergySnapshot(projectedIds, scenario); + const comparison = compareSortieSynergy(current, projected); + return { + mode: selected ? 'remove' : 'add', + headline: `${selected ? '해제 시' : '합류 시'} 공명 ${current.activeBondCount}→${projected.activeBondCount} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3`, + detail: `${roleLabel} · 군영 의정 동행 · 실제 명단 미변경`, + compactLabel: `공명 ${comparison.activeBondDelta > 0 ? '+' : ''}${comparison.activeBondDelta} · 역할 ${comparison.roleCoverageDelta > 0 ? '+' : ''}${comparison.roleCoverageDelta}`, + current, + projected, + comparison + }; + } + if (selected && this.isRequiredSortieUnit(unit.id)) { const relatedBonds = current.activeBonds.filter((bond) => bond.unitIds.includes(unit.id)); - const strongest = relatedBonds[0]; + const strongestBond = relatedBonds[0]; + const pursuitPartner = this.sortiePursuitPartnerForUnit(unit.id, currentPursuitPairs); return { mode: 'current', - headline: `필수 편성 · 공명 ${relatedBonds.length}개 연결 · ${roleLabel} 유지`, - detail: strongest - ? `${strongest.title} Lv${strongest.level} · 조건 충족 시 피해 +${strongest.damageBonus}% / 연계 ${strongest.chainRate}%` - : `현재 활성 공명 없음 · 지형 ${current.terrainGrade} ${current.terrainScore}`, - compactLabel: `필수 · 공명 ${relatedBonds.length}`, + headline: pursuitPartner + ? `필수 편성 · 추격 후보 ${pursuitPartner.partnerName} ${pursuitPartner.pair.chainRate}% · ${roleLabel} 유지` + : `필수 편성 · 추격 후보 없음 · 공명 ${relatedBonds.length}개 · ${roleLabel} 유지`, + detail: pursuitPartner + ? `추격 후보 ${this.sortiePursuitPairLabel(pursuitPartner.pair)} · 지원 조건 충족 시 확률 판정 · 공명 피해 +${pursuitPartner.pair.damageBonus}% · 지형 ${current.terrainGrade} ${current.terrainScore}` + : strongestBond + ? `공명 ${strongestBond.title} Lv${strongestBond.level} · 공명 피해 +${strongestBond.damageBonus}% · 추격 미개방 · 지형 ${current.terrainGrade} ${current.terrainScore}` + : `현재 활성 공명 없음 · 지형 ${current.terrainGrade} ${current.terrainScore}`, + compactLabel: pursuitPartner + ? `↔${pursuitPartner.partnerName} ${pursuitPartner.pair.chainRate}% · 후보 ${currentPursuitPairs.filter((pair) => pair.unitIds.includes(unit.id)).length}조` + : `필수 · 추격 후보 0조 · 공명 ${relatedBonds.length}`, current }; } if (!selected && this.selectedSortieUnitIds.length >= this.sortieMaxUnits(scenario)) { - const selectedIds = new Set(this.selectedSortieUnitIds); - const potentialBonds = this.sortieSynergyBonds(scenario) - .filter((bond) => bond.unitIds.includes(unit.id) && bond.unitIds.some((unitId) => unitId !== unit.id && selectedIds.has(unitId))) - .sort((left, right) => right.level - left.level || left.title.localeCompare(right.title)); - const strongest = potentialBonds[0]; - const strongestPartnerId = strongest?.unitIds.find((unitId) => unitId !== unit.id); + const opportunities = this.sortiePursuitPairs( + this.selectedSortieUnitIds, + scenario, + this.sortieFormationAssignments, + [unit.id] + ).selectableOpportunities + .filter((opportunity) => opportunity.candidateUnitId === unit.id) + .sort((left, right) => this.compareSortiePursuitPairs(left, right)); + const strongest = opportunities[0]; + const topRate = strongest?.chainRate ?? 0; const terrainScore = this.sortieTerrainScore(unit, scenario); return { mode: 'waiting', headline: strongest - ? `교대 후보 · ${potentialBonds.length}개 공명 연결 가능` - : `교대 후보 · 연결 가능한 공명 없음`, + ? `교대 후보 · 추격 기회 ${opportunities.length}조 · 최고 ${topRate}%` + : '교대 후보 · 추격 기회 0조', detail: strongest - ? `${this.unitName(strongestPartnerId ?? '')}와 ${strongest.title} Lv${strongest.level} · ${roleLabel} · 지형 ${this.sortieTerrainGrade(terrainScore)}` + ? `추격 기회 ${this.sortiePursuitPairLabel(strongest)} · ${roleLabel} · 지형 ${this.sortieTerrainGrade(terrainScore)} ${terrainScore}` : `${roleLabel} 후보 · 지형 ${this.sortieTerrainGrade(terrainScore)} ${terrainScore} · 먼저 슬롯을 비우십시오.`, - compactLabel: `교대 · 공명 ${potentialBonds.length}`, + compactLabel: `추격 기회 ${opportunities.length}조 · 최고 ${topRate}%`, current }; } @@ -15775,25 +15967,31 @@ export class CampScene extends Phaser.Scene { : [...this.selectedSortieUnitIds, unit.id]; const projected = this.sortieSynergySnapshot(projectedIds, scenario); const comparison = compareSortieSynergy(current, projected); + const pursuitChanges = this.changedSortiePursuitPairs(current, projected); const prefix = selected ? '해제 시' : '합류 시'; - const changedBonds = selected ? comparison.lostBonds : comparison.gainedBonds; - const bondMark = selected ? '−' : '+'; - const bondDetail = changedBonds.length > 0 - ? `${bondMark} ${changedBonds.slice(0, 2).map((bond) => `${bond.title} Lv${bond.level}`).join(' · ')}` - : '공명 변화 없음'; - const strongest = projected.strongestBond; + const changedPairs = selected ? pursuitChanges.lostPairs : pursuitChanges.gainedPairs; + const changeLabel = selected ? '추격 해제' : '추격 개방'; + const pursuitDetail = changedPairs.length > 0 + ? changedPairs.slice(0, 2).map((pair) => `${changeLabel} ${this.sortiePursuitPairLabel(pair)}`).join(' · ') + : '추격 변화 없음'; + const strongest = [...(selected ? pursuitChanges.currentPairs : pursuitChanges.projectedPairs)] + .sort((left, right) => this.compareSortiePursuitPairs(left, right))[0]; + const currentPartner = selected ? this.sortiePursuitPartnerForUnit(unit.id, pursuitChanges.currentPairs) : undefined; const terrainLine = `지형 ${current.terrainGrade} ${current.terrainScore}→${projected.terrainGrade} ${projected.terrainScore}`; const strongestLine = strongest - ? `최강 피해 +${strongest.damageBonus}% / 연계 ${strongest.chainRate}%` - : '활성 공명 없음'; - const signedBondDelta = comparison.activeBondDelta > 0 ? `+${comparison.activeBondDelta}` : `${comparison.activeBondDelta}`; - const signedRoleDelta = comparison.roleCoverageDelta > 0 ? `+${comparison.roleCoverageDelta}` : `${comparison.roleCoverageDelta}`; + ? `최고 ${strongest.chainRate}% · 공명 피해 +${strongest.damageBonus}%` + : '추격 후보 없음'; + const topRate = changedPairs[0]?.chainRate ?? 0; return { mode: selected ? 'remove' : 'add', - headline: `${prefix} 공명 ${current.activeBondCount}→${projected.activeBondCount} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3`, - detail: `${bondDetail} · ${strongestLine} · ${terrainLine}`, - compactLabel: `공명 ${signedBondDelta} · 역할 ${signedRoleDelta}`, + headline: `${prefix} 추격 후보 ${pursuitChanges.currentPairs.length}→${pursuitChanges.projectedPairs.length} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3`, + detail: `${pursuitDetail} · ${strongestLine} · ${roleLabel} · ${terrainLine}`, + compactLabel: selected + ? currentPartner + ? `↔${currentPartner.partnerName} ${currentPartner.pair.chainRate}% · 추격 해제 ${changedPairs.length}조` + : `추격 해제 ${changedPairs.length}조 · 최고 ${topRate}%` + : `추격 개방 ${changedPairs.length}조 · 최고 ${topRate}%`, current, projected, comparison @@ -16856,6 +17054,7 @@ export class CampScene extends Phaser.Scene { this.sortieFormationPanelMode = 'roster'; } this.sortieComparisonPanelView = undefined; + this.sortiePursuitPanelView = undefined; this.sortiePresetBrowserView = undefined; this.sortiePresetToggleButton = undefined; this.sortieOrderBrowserView = undefined; @@ -20000,6 +20199,14 @@ export class CampScene extends Phaser.Scene { return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; } + private sortieTextBoundsDebug(object?: Phaser.GameObjects.Text) { + 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(); @@ -20016,6 +20223,7 @@ 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 sortiePursuit = this.sortiePursuitPairs(); const recommendedPresetId = this.recommendedSortiePresetId(); const sortieFormationPresets = this.sortieFormationPresets(); const sortieOrderId = this.currentSortieOrderId(); @@ -20483,6 +20691,40 @@ export class CampScene extends Phaser.Scene { })), sortiePlan: this.sortiePlanSummary(), sortieSynergyPreview: this.sortieSynergySnapshot(), + sortiePursuitPreview: { + activePairs: sortiePursuit.activePairs.map((pair) => ({ + id: pair.id, + unitIds: [...pair.unitIds], + unitNames: [...pair.unitNames], + title: pair.title, + level: pair.level, + chainRate: pair.chainRate, + damageBonus: pair.damageBonus + })), + selectableOpportunities: sortiePursuit.selectableOpportunities.map((opportunity) => ({ + id: opportunity.id, + unitIds: [...opportunity.unitIds], + unitNames: [...opportunity.unitNames], + title: opportunity.title, + level: opportunity.level, + chainRate: opportunity.chainRate, + damageBonus: opportunity.damageBonus, + selectedPartnerId: opportunity.selectedPartnerId, + selectedPartnerName: opportunity.selectedPartnerName, + candidateUnitId: opportunity.candidateUnitId, + candidateUnitName: opportunity.candidateUnitName + })), + panelBounds: this.sortieObjectBoundsDebug(this.sortiePursuitPanelView?.background), + summaryText: this.sortiePursuitPanelView?.summaryText.text ?? null, + ruleText: this.sortiePursuitPanelView?.ruleText.text ?? null, + ruleBounds: this.sortieTextBoundsDebug(this.sortiePursuitPanelView?.ruleText), + rows: this.sortiePursuitPanelView?.rows.map((row) => ({ + state: row.state, + text: row.text, + bounds: this.sortieObjectBoundsDebug(row.background), + textBounds: this.sortieTextBoundsDebug(row.label) + })) ?? [] + }, sortieFocusedSynergyPreview: focusedSortieUnit ? this.sortieUnitSynergyPreview(focusedSortieUnit) : null,