From 51529e51c27f2073704a1da7a4070eff93ffa117 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 11 Jul 2026 22:05:33 +0900 Subject: [PATCH] feat: add selectable core resonance pairs --- scripts/verify-battle-save-normalization.mjs | 84 ++- .../verify-campaign-save-normalization.mjs | 170 ++++++ scripts/verify-release-candidate.mjs | 453 +++++++++++++++- scripts/verify-sortie-synergy.mjs | 47 ++ src/game/data/sortieSynergy.ts | 45 +- src/game/scenes/BattleScene.ts | 280 ++++++++-- src/game/scenes/CampScene.ts | 482 ++++++++++++++++-- src/game/state/battleSaveState.ts | 141 ++++- src/game/state/campaignState.ts | 91 ++++ 9 files changed, 1693 insertions(+), 100 deletions(-) diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index 8d49a13..b258337 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -7,7 +7,7 @@ const server = await createServer({ }); try { - const { isValidBattleSaveState, normalizeBattleSaveSlot, parseBattleSaveState } = await server.ssrLoadModule( + const { isValidBattleSaveState, normalizeBattleSaveSlot, normalizeBattleSaveState, parseBattleSaveState } = await server.ssrLoadModule( '/src/game/state/battleSaveState.ts' ); @@ -61,6 +61,88 @@ try { isValidBattleSaveState(legacyStateWithoutSortieOrder, options), 'Expected legacy battle saves without a sortie order to remain valid.' ); + const validCoreResonanceState = { + ...validState, + sortieResonanceBondId: 'taoyuan', + bonds: validState.bonds.map((bond) => ({ ...bond, unitIds: [...bond.unitIds], level: 30 })), + coreResonanceStats: { + attempts: 3, + successes: 1, + failures: 2, + additionalDamage: 9 + } + }; + assert( + isValidBattleSaveState(validCoreResonanceState, options), + 'Expected a deployed level-30 core resonance pair and its pursuit totals to pass validation.' + ); + const parsedCoreResonanceState = parseBattleSaveState(JSON.stringify(validCoreResonanceState), options); + assert( + parsedCoreResonanceState?.sortieResonanceBondId === 'taoyuan' && + JSON.stringify(parsedCoreResonanceState.coreResonanceStats) === JSON.stringify(validCoreResonanceState.coreResonanceStats), + 'Expected core resonance selection and pursuit totals to round-trip.' + ); + const deepClonedCoreResonanceState = normalizeBattleSaveState(validCoreResonanceState, options); + assert( + deepClonedCoreResonanceState && + deepClonedCoreResonanceState !== validCoreResonanceState && + deepClonedCoreResonanceState.bonds !== validCoreResonanceState.bonds && + deepClonedCoreResonanceState.bonds[0].unitIds !== validCoreResonanceState.bonds[0].unitIds && + deepClonedCoreResonanceState.coreResonanceStats !== validCoreResonanceState.coreResonanceStats, + 'Expected battle-save normalization to return a deep clone.' + ); + deepClonedCoreResonanceState.coreResonanceStats.additionalDamage = 99; + deepClonedCoreResonanceState.bonds[0].unitIds[0] = 'mutated-unit'; + assert( + validCoreResonanceState.coreResonanceStats.additionalDamage === 9 && + validCoreResonanceState.bonds[0].unitIds[0] === 'liu-bei', + 'Expected normalized core resonance state mutations not to leak into the source state.' + ); + const normalizedUnsafeCoreStats = parseBattleSaveState(JSON.stringify({ + ...validCoreResonanceState, + coreResonanceStats: { + attempts: 3.8, + successes: 1.9, + failures: -4, + additionalDamage: -9.5 + } + }), options); + assert( + JSON.stringify(normalizedUnsafeCoreStats?.coreResonanceStats) === JSON.stringify({ + attempts: 3, + successes: 1, + failures: 2, + additionalDamage: 0 + }), + 'Expected negative and fractional core resonance totals to normalize to safe integers.' + ); + const normalizedUnknownCoreBond = parseBattleSaveState(JSON.stringify({ + ...validCoreResonanceState, + sortieResonanceBondId: 'missing-bond' + }), options); + assert( + normalizedUnknownCoreBond && + normalizedUnknownCoreBond.sortieResonanceBondId === undefined && + normalizedUnknownCoreBond.coreResonanceStats === undefined, + 'Expected an unknown core resonance bond and its unrelated totals to be removed safely.' + ); + const normalizedUnderleveledCoreBond = parseBattleSaveState(JSON.stringify({ + ...validCoreResonanceState, + bonds: validState.bonds.map((bond) => ({ ...bond, unitIds: [...bond.unitIds], level: 29 })) + }), options); + assert( + normalizedUnderleveledCoreBond && + normalizedUnderleveledCoreBond.sortieResonanceBondId === undefined && + normalizedUnderleveledCoreBond.coreResonanceStats === undefined, + 'Expected an underleveled core resonance pair to be cleared during normalization.' + ); + assert( + !isValidBattleSaveState({ + ...validCoreResonanceState, + coreResonanceStats: { attempts: 2, successes: 1.5, failures: 1, additionalDamage: -1 } + }, options), + 'Expected direct validation to reject unsafe core resonance totals before normalization.' + ); const validSortieStatsState = { ...validState, battleStats: { diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 2bc64c1..d78eec9 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -44,6 +44,7 @@ try { setCampaignReserveTrainingFocus, setCampaignReserveTrainingAssignment, setCampaignSortieOrderSelection, + setCampaignSortieResonanceSelection, setFirstBattleReport } = await server.ssrLoadModule('/src/game/state/campaignState.ts'); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); @@ -491,6 +492,174 @@ try { `Expected clearing the matching battle order to remove the scoped selection: ${JSON.stringify(clearedOrderSelection.sortieOrderSelection)}` ); + const coreResonanceFixture = { + version: 1, + updatedAt: '2026-07-03T11:15:00.000Z', + step: 'first-camp', + roster: [ + { id: 'liu-bei', name: 'Liu Bei', faction: 'ally' }, + { id: 'guan-yu', name: 'Guan Yu', faction: 'ally' }, + { id: 'zhang-fei', name: 'Zhang Fei', faction: 'ally' } + ], + bonds: [ + { id: 'liu-bei__guan-yu', title: 'Oath Bond', unitIds: ['liu-bei', 'guan-yu'], level: 30, exp: 0, battleExp: 0 }, + { id: 'liu-bei__zhang-fei', title: 'Young Bond', unitIds: ['liu-bei', 'zhang-fei'], level: 29, exp: 0, battleExp: 0 } + ], + selectedSortieUnitIds: ['liu-bei', 'guan-yu'], + sortieResonanceSelection: { + battleId: 'first-battle-zhuo-commandery', + bondId: 'liu-bei__guan-yu', + extra: true + } + }; + storage.clear(); + storage.set(campaignStorageKey, JSON.stringify(coreResonanceFixture)); + const normalizedCoreResonance = loadCampaignState(); + assert( + JSON.stringify(normalizedCoreResonance.sortieResonanceSelection) === JSON.stringify({ + battleId: 'first-battle-zhuo-commandery', + bondId: 'liu-bei__guan-yu' + }), + `Expected an eligible deployed Lv30 bond to retain a battle-scoped core resonance selection: ${JSON.stringify(normalizedCoreResonance.sortieResonanceSelection)}` + ); + const detachedCoreResonance = getCampaignState(); + detachedCoreResonance.sortieResonanceSelection.bondId = 'mutated'; + assert( + getCampaignState().sortieResonanceSelection?.bondId === 'liu-bei__guan-yu', + 'Expected core resonance selections returned from campaign state to be deep clones.' + ); + const invalidCoreResonanceSet = setCampaignSortieResonanceSelection('first-battle-zhuo-commandery', 'liu-bei__zhang-fei'); + const unknownCoreResonanceBattle = setCampaignSortieResonanceSelection('unknown-battle', 'liu-bei__guan-yu'); + const prototypeKeyCoreResonanceBattle = setCampaignSortieResonanceSelection('constructor', 'liu-bei__guan-yu'); + assert( + invalidCoreResonanceSet.sortieResonanceSelection?.bondId === 'liu-bei__guan-yu' && + unknownCoreResonanceBattle.sortieResonanceSelection?.bondId === 'liu-bei__guan-yu' && + prototypeKeyCoreResonanceBattle.sortieResonanceSelection?.bondId === 'liu-bei__guan-yu', + `Expected ineligible bonds and unknown/prototype-key battles not to disturb the current core resonance: ${JSON.stringify({ invalidCoreResonanceSet, unknownCoreResonanceBattle, prototypeKeyCoreResonanceBattle })}` + ); + const clearedOtherBattleCoreResonance = setCampaignSortieResonanceSelection('second-battle-yellow-turban-pursuit'); + assert( + clearedOtherBattleCoreResonance.sortieResonanceSelection?.bondId === 'liu-bei__guan-yu', + 'Expected clearing another battle not to remove the current battle-scoped core resonance.' + ); + const clearedCoreResonance = setCampaignSortieResonanceSelection('first-battle-zhuo-commandery'); + assert(clearedCoreResonance.sortieResonanceSelection === undefined, 'Expected the matching core resonance selection to clear explicitly.'); + const selectedCoreResonance = setCampaignSortieResonanceSelection('second-battle-yellow-turban-pursuit', 'liu-bei__guan-yu'); + const reloadedCoreResonance = loadCampaignState(); + assert( + selectedCoreResonance.sortieResonanceSelection?.battleId === 'second-battle-yellow-turban-pursuit' && + reloadedCoreResonance.sortieResonanceSelection?.bondId === 'liu-bei__guan-yu', + `Expected the setter to persist a valid core resonance selection: ${JSON.stringify(reloadedCoreResonance.sortieResonanceSelection)}` + ); + + storage.clear(); + storage.set(campaignStorageKey, JSON.stringify(coreResonanceFixture)); + loadCampaignState(); + const memberRemovedCoreResonance = getCampaignState(); + memberRemovedCoreResonance.selectedSortieUnitIds = ['liu-bei']; + const normalizedMemberRemovedCoreResonance = saveCampaignState(memberRemovedCoreResonance); + assert( + normalizedMemberRemovedCoreResonance.sortieResonanceSelection === undefined, + `Expected removing either core member from the sortie to clear the selection: ${JSON.stringify(normalizedMemberRemovedCoreResonance.sortieResonanceSelection)}` + ); + + const invalidCoreResonanceFixtures = [ + { + label: 'unknown battle', + value: { ...coreResonanceFixture, sortieResonanceSelection: { battleId: 'unknown-battle', bondId: 'liu-bei__guan-yu' } } + }, + { + label: 'prototype-key battle', + value: { ...coreResonanceFixture, sortieResonanceSelection: { battleId: 'constructor', bondId: 'liu-bei__guan-yu' } } + }, + { + label: 'unknown bond', + value: { ...coreResonanceFixture, sortieResonanceSelection: { battleId: 'first-battle-zhuo-commandery', bondId: 'ghost-bond' } } + }, + { + label: 'below Lv30', + value: { + ...coreResonanceFixture, + selectedSortieUnitIds: ['liu-bei', 'zhang-fei'], + sortieResonanceSelection: { battleId: 'first-battle-zhuo-commandery', bondId: 'liu-bei__zhang-fei' } + } + }, + { + label: 'undeployed member', + value: { ...coreResonanceFixture, selectedSortieUnitIds: ['liu-bei'] } + } + ]; + for (const fixture of invalidCoreResonanceFixtures) { + storage.clear(); + storage.set(campaignStorageKey, JSON.stringify(fixture.value)); + const normalizedInvalidCoreResonance = loadCampaignState(); + assert( + normalizedInvalidCoreResonance.sortieResonanceSelection === undefined, + `Expected ${fixture.label} core resonance selection to normalize away: ${JSON.stringify(normalizedInvalidCoreResonance.sortieResonanceSelection)}` + ); + } + + const firstBattleBondIds = new Set(battleScenarios['first-battle-zhuo-commandery'].bonds.map((bond) => bond.id)); + const crossBattleResonance = Object.entries(battleScenarios) + .filter(([battleId]) => battleId !== 'first-battle-zhuo-commandery') + .flatMap(([battleId, scenario]) => scenario.bonds.map((bond) => ({ battleId, bond }))) + .find(({ bond }) => bond.level >= 30 && !firstBattleBondIds.has(bond.id)); + assert(crossBattleResonance, 'Expected campaign scenarios to expose a later-battle-only eligible bond fixture.'); + const crossBattleResonanceFixture = { + ...coreResonanceFixture, + roster: crossBattleResonance.bond.unitIds.map((unitId) => ({ id: unitId, name: unitId, faction: 'ally' })), + bonds: [{ ...crossBattleResonance.bond, battleExp: 0 }], + selectedSortieUnitIds: [...crossBattleResonance.bond.unitIds], + sortieResonanceSelection: { + battleId: 'first-battle-zhuo-commandery', + bondId: crossBattleResonance.bond.id + } + }; + storage.clear(); + storage.set(campaignStorageKey, JSON.stringify(crossBattleResonanceFixture)); + const normalizedCrossBattleResonance = loadCampaignState(); + const rejectedCrossBattleResonance = setCampaignSortieResonanceSelection( + 'first-battle-zhuo-commandery', + crossBattleResonance.bond.id + ); + const acceptedOwningBattleResonance = setCampaignSortieResonanceSelection( + crossBattleResonance.battleId, + crossBattleResonance.bond.id + ); + assert( + normalizedCrossBattleResonance.sortieResonanceSelection === undefined && + rejectedCrossBattleResonance.sortieResonanceSelection === undefined && + acceptedOwningBattleResonance.sortieResonanceSelection?.battleId === crossBattleResonance.battleId && + loadCampaignState().sortieResonanceSelection?.bondId === crossBattleResonance.bond.id, + `Expected cross-battle bonds to be rejected while the same bond remains valid for its owning battle: ${JSON.stringify({ crossBattleResonance, normalizedCrossBattleResonance, rejectedCrossBattleResonance, acceptedOwningBattleResonance })}` + ); + + const scenarioOnlyResonanceFixture = { + ...crossBattleResonanceFixture, + bonds: [], + sortieResonanceSelection: { + battleId: crossBattleResonance.battleId, + bondId: crossBattleResonance.bond.id + } + }; + storage.clear(); + storage.set(campaignStorageKey, JSON.stringify(scenarioOnlyResonanceFixture)); + const normalizedScenarioOnlyResonance = loadCampaignState(); + setCampaignSortieResonanceSelection(crossBattleResonance.battleId); + const selectedScenarioOnlyResonance = setCampaignSortieResonanceSelection( + crossBattleResonance.battleId, + crossBattleResonance.bond.id + ); + const reloadedScenarioOnlyResonance = loadCampaignState(); + assert( + normalizedScenarioOnlyResonance.bonds.length === 0 && + normalizedScenarioOnlyResonance.sortieResonanceSelection?.bondId === crossBattleResonance.bond.id && + selectedScenarioOnlyResonance.sortieResonanceSelection?.battleId === crossBattleResonance.battleId && + reloadedScenarioOnlyResonance.bonds.length === 0 && + reloadedScenarioOnlyResonance.sortieResonanceSelection?.bondId === crossBattleResonance.bond.id, + `Expected a scenario-owned eligible bond to remain selectable and reload safely before campaign progress contains it: ${JSON.stringify({ normalizedScenarioOnlyResonance, selectedScenarioOnlyResonance, reloadedScenarioOnlyResonance })}` + ); + storage.clear(); storage.set( campaignStorageKey, @@ -1251,6 +1420,7 @@ try { typeof legacy.sortieFormationPresets === 'object' && Object.keys(legacy.sortieFormationPresets).length === 0 && legacy.sortieOrderSelection === undefined && + legacy.sortieResonanceSelection === undefined && typeof legacy.sortieOrderHistory === 'object' && Object.keys(legacy.sortieOrderHistory).length === 0 && Array.isArray(legacy.claimedSortieOrderRewardIds) && diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 393f07c..7b34d95 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -2343,6 +2343,8 @@ try { title: '도원결의', level: 72, chainRate: 18, + baseChainRate: 18, + core: false, damageBonus: 9 }, { @@ -2352,6 +2354,8 @@ try { title: '의형제', level: 68, chainRate: 8, + baseChainRate: 8, + core: false, damageBonus: 9 }, { @@ -2361,6 +2365,8 @@ try { title: '맹장 공명', level: 64, chainRate: 8, + baseChainRate: 8, + core: false, damageBonus: 9 } ]; @@ -2368,29 +2374,150 @@ try { const logicalViewportBounds = { x: 0, y: 0, width: 1280, height: 720 }; assert( sameJsonValue(firstSortiePursuit?.activePairs, expectedFirstSortiePursuitPairs) && - firstSortiePursuit?.selectableOpportunities?.length === 0, + firstSortiePursuit?.selectableOpportunities?.length === 0 && + firstSortiePursuit?.selectedCoreBondId === null && + firstSortiePursuit?.selectionBattleId === null && + sameJsonValue( + firstSortiePursuit?.coreCandidates?.map((candidate) => ({ + id: candidate.id, + level: candidate.level, + baseChainRate: candidate.baseChainRate, + coreChainRate: candidate.coreChainRate, + selected: candidate.selected + })), + [ + { id: 'liu-bei__guan-yu', level: 72, baseChainRate: 18, coreChainRate: 28, selected: false }, + { id: 'liu-bei__zhang-fei', level: 68, baseChainRate: 8, coreChainRate: 18, selected: false }, + { id: 'guan-yu__zhang-fei', level: 64, baseChainRate: 8, coreChainRate: 18, selected: false } + ] + ), `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 === '같은 적을 먼저 친 공명 장수가 지원 거리 안에서 표시 확률로 추격합니다.' && + firstSortiePursuit?.summaryText === '핵심 공명조 · 미지정 · 후보 3조' && + firstSortiePursuit?.ruleText === 'Lv30+ 한 조 · 핵심 추격 8/18/28% · 재클릭 해제' && isFiniteBounds(firstSortiePursuit.panelBounds) && isFiniteBounds(firstSortiePursuit.ruleBounds) && + firstSortiePursuit.page === 0 && + firstSortiePursuit.pageCount === 1 && + firstSortiePursuit.pageText === '1/1' && + firstSortiePursuit.prevEnabled === false && + firstSortiePursuit.nextEnabled === false && + isFiniteBounds(firstSortiePursuit.prevBounds) && + isFiniteBounds(firstSortiePursuit.nextBounds) && + firstSortiePursuit.prevClickBounds === null && + firstSortiePursuit.nextClickBounds === null && + boundsInside(firstSortiePursuit.prevBounds, firstSortiePursuit.panelBounds) && + boundsInside(firstSortiePursuit.nextBounds, firstSortiePursuit.panelBounds) && 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% 관우↔장비' } + sameJsonValue(firstSortiePursuit.rows?.map((row) => ({ bondId: row.bondId, state: row.state, selected: row.selected, chainRate: row.chainRate, text: row.text })), [ + { bondId: 'liu-bei__guan-yu', state: 'candidate', selected: false, chainRate: 28, text: '지정 28% 유비↔관우' }, + { bondId: 'liu-bei__zhang-fei', state: 'candidate', selected: false, chainRate: 18, text: '지정 18% 유비↔장비' }, + { bondId: 'guan-yu__zhang-fei', state: 'candidate', selected: false, chainRate: 18, text: '지정 18% 관우↔장비' } ]) && firstSortiePursuit.rows.every( (row) => isFiniteBounds(row.bounds) && + isFiniteBounds(row.chipBounds) && isFiniteBounds(row.textBounds) && + sameJsonValue(row.bounds, row.chipBounds) && 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)}` + `Expected all three interactive core-resonance chips and their labels to remain visible inside the first-sortie panel: ${JSON.stringify(firstSortiePursuit)}` + ); + + const coreResonanceToggleTarget = firstSortiePursuit.rows.find( + (row) => row.bondId === 'liu-bei__zhang-fei' + ); + assert( + isFiniteBounds(coreResonanceToggleTarget?.chipBounds), + `Expected the lower-level brother pair to expose an interactive core-resonance chip: ${JSON.stringify(coreResonanceToggleTarget)}` + ); + await page.mouse.click( + coreResonanceToggleTarget.chipBounds.x + coreResonanceToggleTarget.chipBounds.width / 2, + coreResonanceToggleTarget.chipBounds.y + coreResonanceToggleTarget.chipBounds.height / 2 + ); + await page.waitForFunction(() => { + const pursuit = window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview; + return pursuit?.selectedCoreBondId === 'liu-bei__zhang-fei' && + pursuit?.rows?.some((row) => row.bondId === 'liu-bei__zhang-fei' && row.selected === true); + }, undefined, { timeout: 30000 }); + const selectedCoreResonance = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); + const selectedCoreResonanceSave = await readCampaignSave(page); + await page.screenshot({ path: `${screenshotDir}/rc-first-camp-core-resonance-selected.png`, fullPage: true }); + assert( + selectedCoreResonance?.selectionBattleId === 'second-battle-yellow-turban-pursuit' && + selectedCoreResonance?.selectedCoreBondId === 'liu-bei__zhang-fei' && + selectedCoreResonance?.summaryText === '핵심 공명조 · 지정 · 후보 3조' && + selectedCoreResonance?.activePairs?.[0]?.id === 'liu-bei__zhang-fei' && + selectedCoreResonance.activePairs[0].core === true && + selectedCoreResonance.activePairs[0].baseChainRate === 8 && + selectedCoreResonance.activePairs[0].chainRate === 18 && + selectedCoreResonance?.coreCandidates?.[0]?.id === 'liu-bei__zhang-fei' && + selectedCoreResonance.coreCandidates[0].selected === true && + selectedCoreResonance?.rows?.[0]?.bondId === 'liu-bei__zhang-fei' && + selectedCoreResonance.rows[0].state === 'selected' && + selectedCoreResonance.rows[0].text === '핵심 18% 유비↔장비' && + selectedCoreResonanceSave.current?.sortieResonanceSelection?.battleId === 'second-battle-yellow-turban-pursuit' && + selectedCoreResonanceSave.current.sortieResonanceSelection.bondId === 'liu-bei__zhang-fei', + `Expected one clicked pair to become the persisted, prioritized 18% core resonance: ${JSON.stringify({ selectedCoreResonance, save: selectedCoreResonanceSave.current?.sortieResonanceSelection })}` + ); + + const coreResonanceAutoClearFixture = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + if (!scene || typeof scene.toggleSortieUnit !== '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.toggleSortieUnit('zhang-fei'); + return { ready: true, before }; + }); + await page.waitForFunction( + () => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.sortiePursuitPreview?.selectedCoreBondId === null && + !state?.selectedSortieUnitIds?.includes('zhang-fei'); + }, + undefined, + { timeout: 30000 } + ); + const clearedCoreResonance = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); + const clearedCoreCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const clearedCoreResonanceSave = await readCampaignSave(page); + assert( + coreResonanceAutoClearFixture.ready === true && + clearedCoreResonance?.selectedCoreBondId === null && + clearedCoreResonance?.activePairs?.every((pair) => pair.core === false) && + clearedCoreResonance?.rows?.every((row) => row.selected === false && row.state === 'candidate') && + !clearedCoreCampState?.selectedSortieUnitIds?.includes('zhang-fei') && + clearedCoreCampState?.sortiePlanFeedback?.includes('핵심 공명조 자동 해제') && + clearedCoreResonanceSave.current?.sortieResonanceSelection === undefined, + `Expected removing a designated member to clear the persisted core pair and retain ordinary pursuit behavior: ${JSON.stringify({ clearedCoreCampState, clearedCoreResonance, save: clearedCoreResonanceSave.current?.sortieResonanceSelection })}` + ); + await page.evaluate((before) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + 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(); + }, coreResonanceAutoClearFixture.before); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.selectedSortieUnitIds?.includes('zhang-fei') === true, + undefined, + { timeout: 30000 } ); const firstSortiePursuitSaveBefore = await readCampaignSave(page); @@ -2598,9 +2725,33 @@ try { 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)}` ); + + const launchCoreResonanceRow = firstSortiePursuitRestored.pursuit?.rows?.find( + (row) => row.bondId === 'liu-bei__zhang-fei' + ); + assert( + isFiniteBounds(launchCoreResonanceRow?.chipBounds), + `Expected the restored formation to keep the Liu Bei/Zhang Fei core-resonance control available: ${JSON.stringify(firstSortiePursuitRestored.pursuit)}` + ); + await page.mouse.click( + launchCoreResonanceRow.chipBounds.x + launchCoreResonanceRow.chipBounds.width / 2, + launchCoreResonanceRow.chipBounds.y + launchCoreResonanceRow.chipBounds.height / 2 + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.selectedCoreBondId === 'liu-bei__zhang-fei', + undefined, + { timeout: 30000 } + ); + const launchCoreResonance = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); + assert( + launchCoreResonance?.activePairs?.[0]?.id === 'liu-bei__zhang-fei' && + launchCoreResonance.activePairs[0].core === true && + launchCoreResonance.activePairs[0].chainRate === 18, + `Expected the lower legacy-rate pair to be promoted and prioritized before battle launch: ${JSON.stringify(launchCoreResonance)}` + ); await advanceSortiePrepStep(page, 'loadout'); await page.mouse.click(1116, 656); await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit'); @@ -2646,6 +2797,120 @@ try { secondBattleProbe.sortieDoctrine?.activeBondCount === 3, `Expected the second battle doctrine to expose one officer per role and all three active bonds: ${JSON.stringify(secondBattleProbe.sortieDoctrine)}` ); + assert( + secondBattleProbe.coreSortieResonance?.selected === true && + secondBattleProbe.coreSortieResonance.selectedBondId === 'liu-bei__zhang-fei' && + secondBattleProbe.coreSortieResonance.valid === true && + secondBattleProbe.coreSortieResonance.active === true && + secondBattleProbe.coreSortieResonance.level === 68 && + secondBattleProbe.coreSortieResonance.rate === 18 && + sameJsonValue(secondBattleProbe.coreSortieResonance.unitIds, ['liu-bei', 'zhang-fei']) && + sameJsonValue(secondBattleProbe.coreSortieResonance.stats, { + attempts: 0, + successes: 0, + failures: 0, + additionalDamage: 0 + }) && + sameJsonValue(secondBattleProbe.sortieResonance, secondBattleProbe.coreSortieResonance), + `Expected the camp designation to reach battle as one active 18% core resonance with clean statistics: ${JSON.stringify(secondBattleProbe.coreSortieResonance)}` + ); + const secondBattleCorePriority = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const attacker = scene?.debugUnitById('liu-bei'); + const corePartner = scene?.debugUnitById('zhang-fei'); + const ordinaryPartner = scene?.debugUnitById('guan-yu'); + const targetState = window.__HEROS_DEBUG__?.battle()?.units?.find((unit) => unit.faction === 'enemy' && unit.hp > 0); + const target = targetState ? scene?.debugUnitById(targetState.id) : undefined; + if (!scene || !attacker || !corePartner || !ordinaryPartner || !target) { + return { ready: false }; + } + + const touchedUnits = [attacker, corePartner, ordinaryPartner, target]; + const original = { + units: touchedUnits.map((unit) => ({ unit, x: unit.x, y: unit.y })), + attackIntents: scene.attackIntents.map((intent) => ({ ...intent })), + actedUnitIds: new Set(scene.actedUnitIds), + targetHp: target.hp, + battleStats: new Map( + [...scene.battleStats.entries()].map(([unitId, stats]) => [unitId, structuredClone(stats)]) + ) + }; + try { + Object.assign(attacker, { x: 5, y: 5 }); + Object.assign(corePartner, { x: 5, y: 6 }); + Object.assign(ordinaryPartner, { x: 4, y: 5 }); + Object.assign(target, { x: 6, y: 5 }); + scene.attackIntents = [ + { attackerId: corePartner.id, targetId: target.id }, + { attackerId: ordinaryPartner.id, targetId: target.id } + ]; + scene.actedUnitIds = new Set([...original.actedUnitIds, corePartner.id, ordinaryPartner.id]); + const candidate = scene.activeBondChainCandidate(attacker, target, 'attack'); + const preview = scene.combatPreview(attacker, target, 'attack'); + const failedResolution = scene.resolveBondChainFollowUp(preview, true, false); + const successfulResolution = scene.resolveBondChainFollowUp(preview, true, true); + const coreStats = window.__HEROS_DEBUG__?.battle()?.coreSortieResonance?.stats ?? null; + return { + ready: true, + candidate: candidate ? { + bondId: candidate.bondId, + partnerId: candidate.partner.id, + rate: candidate.rate, + coreResonance: candidate.coreResonance, + label: candidate.label + } : null, + preview: { + bondId: preview.bondChainBondId ?? null, + partnerId: preview.bondChainPartnerId ?? null, + rate: preview.bondChainRate, + coreResonance: preview.bondChainCoreResonance, + label: preview.bondChainLabel ?? null + }, + failedResolution: failedResolution ? { + succeeded: failedResolution.attempt.succeeded, + coreResonance: failedResolution.attempt.coreResonance, + hasResult: Boolean(failedResolution.result) + } : null, + successfulResolution: successfulResolution ? { + succeeded: successfulResolution.attempt.succeeded, + coreResonance: successfulResolution.attempt.coreResonance, + damage: successfulResolution.result?.damage ?? 0 + } : null, + coreStats + }; + } finally { + original.units.forEach(({ unit, x, y }) => Object.assign(unit, { x, y })); + target.hp = original.targetHp; + scene.battleStats = original.battleStats; + scene.attackIntents = original.attackIntents; + scene.actedUnitIds = original.actedUnitIds; + } + }); + assert( + secondBattleCorePriority.ready === true && + secondBattleCorePriority.candidate?.bondId === 'liu-bei__zhang-fei' && + secondBattleCorePriority.candidate.partnerId === 'zhang-fei' && + secondBattleCorePriority.candidate.rate === 18 && + secondBattleCorePriority.candidate.coreResonance === true && + secondBattleCorePriority.candidate.label.includes('핵심 공명') && + secondBattleCorePriority.preview?.bondId === 'liu-bei__zhang-fei' && + secondBattleCorePriority.preview.partnerId === 'zhang-fei' && + secondBattleCorePriority.preview.rate === 18 && + secondBattleCorePriority.preview.coreResonance === true && + secondBattleCorePriority.failedResolution?.succeeded === false && + secondBattleCorePriority.failedResolution.coreResonance === true && + secondBattleCorePriority.failedResolution.hasResult === false && + secondBattleCorePriority.successfulResolution?.succeeded === true && + secondBattleCorePriority.successfulResolution.coreResonance === true && + secondBattleCorePriority.successfulResolution.damage > 0 && + sameJsonValue(secondBattleCorePriority.coreStats, { + attempts: 2, + successes: 1, + failures: 1, + additionalDamage: secondBattleCorePriority.successfulResolution.damage + }), + `Expected the designated Lv68 pair to outrank Liu Bei's ordinary Lv72 pursuit candidate: ${JSON.stringify(secondBattleCorePriority)}` + ); assert( secondBattleProbe.openingBannerTexts?.some((text) => text.includes('세 형제 생존 중 공명 지원 거리 2칸')) && secondBattleProbe.openingBannerTexts?.some((text) => text.includes('전술 명령')), @@ -2679,7 +2944,13 @@ try { const loadAssignments = (savedCampaignRaw, assignments) => { const campaign = JSON.parse(savedCampaignRaw); campaign.sortieFormationAssignments = assignments; + delete campaign.sortieResonanceSelection; window.localStorage.setItem(campaignSlotKey, JSON.stringify(campaign)); + scene.launchSortieResonanceBondId = undefined; + scene.coreResonancePursuitAttempts = 0; + scene.coreResonancePursuitSuccesses = 0; + scene.coreResonancePursuitFailures = 0; + scene.coreResonancePursuitDamage = 0; scene.loadBattleState?.(1); }; @@ -2691,6 +2962,9 @@ try { loadAssignments(savedCampaignRaw, { 'liu-bei': 'front', 'guan-yu': 'support', 'zhang-fei': 'reserve' }); const changed = captureUnits(); + const changedCoreResonance = structuredClone( + window.__HEROS_DEBUG__?.battle()?.coreSortieResonance ?? null + ); loadAssignments(savedCampaignRaw, { 'liu-bei': 'reserve', 'guan-yu': 'reserve', 'zhang-fei': 'reserve' }); scene.hideBattleEventBanner?.(); @@ -2706,8 +2980,16 @@ try { scene.hideBattleEventBanner?.(); window.localStorage.setItem(campaignSlotKey, savedCampaignRaw); + scene.launchSortieResonanceBondId = undefined; + scene.coreResonancePursuitAttempts = 0; + scene.coreResonancePursuitSuccesses = 0; + scene.coreResonancePursuitFailures = 0; + scene.coreResonancePursuitDamage = 0; scene.loadBattleState?.(1); const restored = captureUnits(); + const restoredCoreResonance = structuredClone( + window.__HEROS_DEBUG__?.battle()?.coreSortieResonance ?? null + ); storageSnapshot.forEach((value, key) => { if (value === null) { window.localStorage.removeItem(key); @@ -2715,7 +2997,7 @@ try { window.localStorage.setItem(key, value); } }); - return { changed, allReserve, restored }; + return { changed, changedCoreResonance, allReserve, restored, restoredCoreResonance }; }); assert( JSON.stringify(roleSealResyncProbe?.changed) === JSON.stringify([ @@ -2725,6 +3007,17 @@ try { ]), `Expected role seals to resync when a loaded formation changes roles: ${JSON.stringify(roleSealResyncProbe)}` ); + assert( + roleSealResyncProbe?.changedCoreResonance?.selectedBondId === 'liu-bei__zhang-fei' && + roleSealResyncProbe.changedCoreResonance.active === true && + sameJsonValue(roleSealResyncProbe.changedCoreResonance.stats, { + attempts: 2, + successes: 1, + failures: 1, + additionalDamage: secondBattleCorePriority.successfulResolution.damage + }), + `Expected battle save data—not the cleared in-memory or campaign fallback—to restore the core designation and totals: ${JSON.stringify(roleSealResyncProbe?.changedCoreResonance)}` + ); assert( roleSealResyncProbe?.allReserve?.doctrineActive === true && roleSealResyncProbe?.allReserve?.coveredRoleCount === 0 && @@ -2739,6 +3032,45 @@ try { ]), `Expected loaded role seals to restore the original formation cleanly: ${JSON.stringify(roleSealResyncProbe?.restored)}` ); + assert( + roleSealResyncProbe?.restoredCoreResonance?.selectedBondId === 'liu-bei__zhang-fei' && + roleSealResyncProbe.restoredCoreResonance.active === true && + roleSealResyncProbe.restoredCoreResonance.rate === 18 && + sameJsonValue(roleSealResyncProbe.restoredCoreResonance.stats, { + attempts: 2, + successes: 1, + failures: 1, + additionalDamage: secondBattleCorePriority.successfulResolution.damage + }), + `Expected battle-slot reloads to preserve the selected core pair and its accumulated pursuit statistics: ${JSON.stringify(roleSealResyncProbe?.restoredCoreResonance)}` + ); + + await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.resultVisible === true, undefined, { timeout: 30000 }); + const secondBattleCoreResult = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const resonance = window.__HEROS_DEBUG__?.battle()?.coreSortieResonance; + const texts = (scene?.resultObjects ?? []) + .filter((object) => object?.type === 'Text') + .map((object) => object.text); + return { + stats: resonance?.stats ?? null, + resultSummary: resonance?.resultSummary ?? null, + summary: texts.find((text) => text.startsWith('핵심 공명 · 시도')) ?? null + }; + }); + await page.screenshot({ path: `${screenshotDir}/rc-second-battle-core-resonance-result.png`, fullPage: true }); + assert( + secondBattleCoreResult.summary === + `핵심 공명 · 시도 2 · 성공 1 · 추가 피해 +${secondBattleCorePriority.successfulResolution.damage}` && + sameJsonValue(secondBattleCoreResult.stats, roleSealResyncProbe.restoredCoreResonance.stats) && + secondBattleCoreResult.resultSummary?.visible === true && + secondBattleCoreResult.resultSummary.text === secondBattleCoreResult.summary && + isFiniteBounds(secondBattleCoreResult.resultSummary.bounds) && + boundsInside(secondBattleCoreResult.resultSummary.bounds, logicalViewportBounds) && + secondBattleCoreResult.resultSummary.bounds.y + secondBattleCoreResult.resultSummary.bounds.height <= 416, + `Expected the victory report to surface persisted core-resonance attempts, successes, and damage: ${JSON.stringify(secondBattleCoreResult)}` + ); await seedCampaignSave(page, { battleId: 'fifty-eighth-battle-qishan-retreat', @@ -2846,6 +3178,107 @@ try { `Expected all 23 campaign officers to use loaded lightweight portrait cards: ${JSON.stringify(missingMidFormationPortraits)}` ); + const midCoreSelectorToggle = midFormationPortraitState?.sortiePursuitPreview; + assert( + midCoreSelectorToggle?.selectorOpen === false && + midCoreSelectorToggle.toggleLabel === '핵심 공명조' && + isFiniteBounds(midCoreSelectorToggle.toggleBounds) && + isFiniteBounds(midCoreSelectorToggle.toggleClickBounds) && + boundsInside(midCoreSelectorToggle.toggleBounds, midCoreSelectorToggle.panelBounds), + `Expected the generic formation comparison panel to expose an in-panel core-resonance mode switch: ${JSON.stringify(midCoreSelectorToggle)}` + ); + await page.mouse.click( + midCoreSelectorToggle.toggleClickBounds.x + midCoreSelectorToggle.toggleClickBounds.width / 2, + midCoreSelectorToggle.toggleClickBounds.y + midCoreSelectorToggle.toggleClickBounds.height / 2 + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.selectorOpen === true, + undefined, + { timeout: 30000 } + ); + const midCoreSelectorState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const midCoreResonancePageOne = midCoreSelectorState?.sortiePursuitPreview; + assert( + midCoreResonancePageOne?.coreCandidates?.length > 3 && + midCoreResonancePageOne.selectorOpen === true && + midCoreResonancePageOne.toggleLabel === '편성 비교' && + midCoreResonancePageOne.page === 0 && + midCoreResonancePageOne.pageCount === Math.ceil(midCoreResonancePageOne.coreCandidates.length / 3) && + midCoreResonancePageOne.rows.length === 3 && + midCoreResonancePageOne.prevEnabled === false && + midCoreResonancePageOne.nextEnabled === true && + midCoreResonancePageOne.prevClickBounds === null && + isFiniteBounds(midCoreResonancePageOne.nextClickBounds) && + boundsInside(midCoreResonancePageOne.nextBounds, midCoreResonancePageOne.panelBounds), + `Expected the mid-campaign formation to paginate more than three eligible core-resonance pairs: ${JSON.stringify(midCoreResonancePageOne)}` + ); + const midCoreFirstPageIds = midCoreResonancePageOne.rows.map((row) => row.bondId); + await page.mouse.click( + midCoreResonancePageOne.nextClickBounds.x + midCoreResonancePageOne.nextClickBounds.width / 2, + midCoreResonancePageOne.nextClickBounds.y + midCoreResonancePageOne.nextClickBounds.height / 2 + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.page === 1, + undefined, + { timeout: 30000 } + ); + const midCoreResonancePageTwo = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); + await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-core-resonance-page-2.png`, fullPage: true }); + assert( + midCoreResonancePageTwo?.page === 1 && + midCoreResonancePageTwo.pageText === `2/${midCoreResonancePageTwo.pageCount}` && + midCoreResonancePageTwo.prevEnabled === true && + isFiniteBounds(midCoreResonancePageTwo.prevClickBounds) && + midCoreResonancePageTwo.rows.length > 0 && + midCoreResonancePageTwo.rows.every( + (row) => !midCoreFirstPageIds.includes(row.bondId) && + isFiniteBounds(row.chipBounds) && + boundsInside(row.chipBounds, midCoreResonancePageTwo.panelBounds) + ), + `Expected the next-page control to expose later eligible resonance pairs inside the HD panel: ${JSON.stringify(midCoreResonancePageTwo)}` + ); + const midCoreLaterCandidate = midCoreResonancePageTwo.rows[0]; + await page.mouse.click( + midCoreLaterCandidate.chipBounds.x + midCoreLaterCandidate.chipBounds.width / 2, + midCoreLaterCandidate.chipBounds.y + midCoreLaterCandidate.chipBounds.height / 2 + ); + await page.waitForFunction( + (bondId) => { + const pursuit = window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview; + return pursuit?.selectedCoreBondId === bondId && pursuit?.page === 0 && pursuit?.rows?.[0]?.bondId === bondId; + }, + midCoreLaterCandidate.bondId, + { timeout: 30000 } + ); + const midCoreLaterSelected = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); + assert( + midCoreLaterSelected?.selectedCoreBondId === midCoreLaterCandidate.bondId && + midCoreLaterSelected.page === 0 && + midCoreLaterSelected.rows[0]?.bondId === midCoreLaterCandidate.bondId && + midCoreLaterSelected.rows[0].selected === true && + midCoreLaterSelected.rows[0].state === 'selected', + `Expected a pair reached through page two to become the visible persisted core designation: ${JSON.stringify(midCoreLaterSelected)}` + ); + await page.mouse.click( + midCoreLaterSelected.rows[0].chipBounds.x + midCoreLaterSelected.rows[0].chipBounds.width / 2, + midCoreLaterSelected.rows[0].chipBounds.y + midCoreLaterSelected.rows[0].chipBounds.height / 2 + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.selectedCoreBondId === null, + undefined, + { timeout: 30000 } + ); + const midCoreSelectorAfterClear = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview); + await page.mouse.click( + midCoreSelectorAfterClear.toggleClickBounds.x + midCoreSelectorAfterClear.toggleClickBounds.width / 2, + midCoreSelectorAfterClear.toggleClickBounds.y + midCoreSelectorAfterClear.toggleClickBounds.height / 2 + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.sortiePursuitPreview?.selectorOpen === false, + undefined, + { timeout: 30000 } + ); + const midFormationSelectedBefore = [...(midFormationPortraitState?.selectedSortieUnitIds ?? [])]; const midFormationCampaignSelectedBefore = [...(midFormationPortraitState?.campaign?.selectedSortieUnitIds ?? [])]; const midFormationAssignmentsBefore = { ...(midFormationPortraitState?.sortieFormationAssignments ?? {}) }; diff --git a/scripts/verify-sortie-synergy.mjs b/scripts/verify-sortie-synergy.mjs index e3ead6b..68d32f8 100644 --- a/scripts/verify-sortie-synergy.mjs +++ b/scripts/verify-sortie-synergy.mjs @@ -9,6 +9,8 @@ const server = await createServer({ try { const { compareSortieSynergy, + coreSortieResonanceChainRateForLevel, + coreSortieResonanceMinLevel, evaluateSortiePursuitPairs, evaluateSortieSynergy, firstPursuitRolePreviewSummaries, @@ -56,6 +58,20 @@ try { `Unexpected Lv${level} bond bonus: ${JSON.stringify(actual)}` ); }); + assert(coreSortieResonanceMinLevel === 30, `Expected core resonance eligibility to begin at Lv30: ${coreSortieResonanceMinLevel}`); + const coreResonanceBoundaryCases = [ + [29, 0], + [30, 8], + [49, 8], + [50, 18], + [69, 18], + [70, 28], + [100, 28] + ]; + coreResonanceBoundaryCases.forEach(([level, chainRate]) => { + const actual = coreSortieResonanceChainRateForLevel(level); + assert(actual === chainRate, `Unexpected Lv${level} core resonance chain rate: ${actual}`); + }); const members = [ { id: 'liu-bei', name: '유비', role: 'support', terrainScore: 101 }, @@ -133,6 +149,7 @@ try { { 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: 'active-30-core', title: 'Core 30', unitIds: ['selected-a', 'selected-b'], level: 30 }, { 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 }, @@ -149,6 +166,11 @@ try { 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.activePairs.every((bond) => bond.core === false && bond.baseChainRate === bond.chainRate) && + pursuitPairs.selectableOpportunities.every((bond) => bond.core === false && bond.baseChainRate === bond.chainRate), + `Expected non-designated pursuit pairs to preserve their legacy rates: ${JSON.stringify(pursuitPairs)}` + ); 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)}` @@ -181,6 +203,31 @@ try { promotedPursuitPairs.selectableOpportunities.every((bond) => bond.candidateUnitId !== 'candidate-70'), `Expected a selected candidate to move from opportunity to active pair: ${JSON.stringify(promotedPursuitPairs)}` ); + const corePursuitPairs = evaluateSortiePursuitPairs({ + members: pursuitMembers, + bonds: pursuitBonds, + selectedUnitIds: ['selected-a', 'selected-b', 'candidate-70'], + coreBondId: 'active-30-core' + }); + assert( + corePursuitPairs.activePairs[0]?.id === 'active-30-core' && + corePursuitPairs.activePairs[0].core === true && + corePursuitPairs.activePairs[0].baseChainRate === 0 && + corePursuitPairs.activePairs[0].chainRate === 8 && + corePursuitPairs.activePairs.some((bond) => bond.id === 'opportunity-70-alpha' && !bond.core && bond.chainRate === 18), + `Expected an eligible Lv30 core pair to gain 8% pursuit and sort ahead of stronger ordinary pairs: ${JSON.stringify(corePursuitPairs.activePairs)}` + ); + const inactiveCorePursuitPairs = evaluateSortiePursuitPairs({ + members: pursuitMembers, + bonds: pursuitBonds, + selectedUnitIds: ['selected-a', 'selected-b'], + coreBondId: 'opportunity-49' + }); + assert( + inactiveCorePursuitPairs.activePairs[0]?.id === 'active-70-alpha' && + inactiveCorePursuitPairs.selectableOpportunities.every((bond) => bond.id !== 'opportunity-49' && !bond.core), + `Expected an ineligible or undeployed core id not to promote a pursuit pair: ${JSON.stringify(inactiveCorePursuitPairs)}` + ); assert( JSON.stringify({ pursuitMembers, pursuitBonds }) === frozenPursuitInputs, 'Expected pursuit-pair evaluation not to mutate its inputs.' diff --git a/src/game/data/sortieSynergy.ts b/src/game/data/sortieSynergy.ts index c032fa2..cfa82a0 100644 --- a/src/game/data/sortieSynergy.ts +++ b/src/game/data/sortieSynergy.ts @@ -1,6 +1,7 @@ import type { SortieFormationRole } from './sortieDeployment'; export const coreSortieSynergyRoles = ['front', 'flank', 'support'] as const; +export const coreSortieResonanceMinLevel = 30; export const firstPursuitSynergyBattleId = 'second-battle-yellow-turban-pursuit'; export const firstPursuitBrotherUnitIds = ['liu-bei', 'guan-yu', 'zhang-fei'] as const; export const sortieRoleEffectSummaries: Partial> = { @@ -60,7 +61,12 @@ export type SortieSynergyComparison = { lostRoles: (typeof coreSortieSynergyRoles)[number][]; }; -export type SortiePursuitOpportunity = SortieSynergyActiveBond & { +export type SortiePursuitPair = SortieSynergyActiveBond & { + baseChainRate: number; + core: boolean; +}; + +export type SortiePursuitOpportunity = SortiePursuitPair & { selectedPartnerId: string; selectedPartnerName: string; candidateUnitId: string; @@ -68,7 +74,7 @@ export type SortiePursuitOpportunity = SortieSynergyActiveBond & { }; export type SortiePursuitPairs = { - activePairs: SortieSynergyActiveBond[]; + activePairs: SortiePursuitPair[]; selectableOpportunities: SortiePursuitOpportunity[]; }; @@ -76,10 +82,13 @@ function compareSortieActiveBonds(left: SortieSynergyActiveBond, right: SortieSy return right.level - left.level || left.title.localeCompare(right.title) || left.id.localeCompare(right.id); } -function strongestSortieBondPairs(bonds: readonly T[]) { +function strongestSortiePursuitPairs(bonds: readonly T[]) { const seenPairs = new Set(); return [...bonds] - .sort(compareSortieActiveBonds) + .sort((left, right) => + Number(right.core) - Number(left.core) || + compareSortieActiveBonds(left, right) + ) .filter((bond) => { const pairKey = [...bond.unitIds].sort().join('\u0000'); if (seenPairs.has(pairKey)) { @@ -98,6 +107,22 @@ export function sortieBondBonusForLevel(level: number) { }; } +export function coreSortieResonanceChainRateForLevel(level: number) { + const normalizedLevel = Math.max(0, Math.floor(Number.isFinite(level) ? level : 0)); + return normalizedLevel >= 70 ? 28 : normalizedLevel >= 50 ? 18 : normalizedLevel >= coreSortieResonanceMinLevel ? 8 : 0; +} + +function sortiePursuitPairForBond(bond: SortieSynergyActiveBond, coreBondId?: string): SortiePursuitPair { + const baseChainRate = bond.chainRate; + const coreChainRate = bond.id === coreBondId ? coreSortieResonanceChainRateForLevel(bond.level) : 0; + return { + ...bond, + baseChainRate, + chainRate: coreChainRate > 0 ? coreChainRate : baseChainRate, + core: coreChainRate > 0 + }; +} + export function sortieTerrainGrade(score: number) { if (score >= 110) { return '최적'; @@ -170,17 +195,23 @@ export function evaluateSortiePursuitPairs(options: { selectedUnitIds: readonly string[]; selectableUnitIds?: readonly string[]; battleId?: string; + coreBondId?: string; }): SortiePursuitPairs { const current = evaluateSortieSynergy(options); + const activeCoreBondId = current.activeBonds.some((bond) => + bond.id === options.coreBondId && bond.level >= coreSortieResonanceMinLevel + ) ? options.coreBondId : undefined; + const activePairs = current.activeBonds.map((bond) => sortiePursuitPairForBond(bond, activeCoreBondId)); 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 selectableOpportunities = strongestSortiePursuitPairs(selectableUnitIds.flatMap((candidateUnitId) => { const projected = evaluateSortieSynergy({ ...options, selectedUnitIds: [...current.selectedUnitIds, candidateUnitId] }); - return projected.activeBonds.flatMap((bond) => { + return projected.activeBonds.flatMap((activeBond) => { + const bond = sortiePursuitPairForBond(activeBond, activeCoreBondId); if (bond.chainRate <= 0 || !bond.unitIds.includes(candidateUnitId)) { return []; } @@ -201,7 +232,7 @@ export function evaluateSortiePursuitPairs(options: { })); return { - activePairs: strongestSortieBondPairs(current.activeBonds.filter((bond) => bond.chainRate > 0)), + activePairs: strongestSortiePursuitPairs(activePairs.filter((bond) => bond.chainRate > 0)), selectableOpportunities }; } diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 1018248..4d0da19 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -43,6 +43,8 @@ import { type SortieFormationRole } from '../data/sortieDeployment'; import { + coreSortieResonanceChainRateForLevel, + coreSortieResonanceMinLevel, coreSortieSynergyRoles, firstPursuitBrotherUnitIds, firstPursuitSynergyBattleId, @@ -89,6 +91,7 @@ import { import { normalizeBattleSaveSlot, parseBattleSaveState, + type BattleSaveCoreResonanceStats, type BattleSaveState, type BattleSaveTacticalCommandRole, type BattleSaveTacticalCommandState, @@ -1273,6 +1276,7 @@ type BattleSceneData = { sortieFormationAssignments?: SortieFormationAssignments; sortieItemAssignments?: CampaignSortieItemAssignments; sortieOrderId?: SortieOrderId; + sortieResonanceBondId?: string; }; type CommandButton = { @@ -1314,6 +1318,7 @@ type BondChainCandidate = { partner: UnitData; rate: number; damage: number; + coreResonance: boolean; }; type BondChainResult = { @@ -1325,6 +1330,7 @@ type BondChainResult = { damage: number; previousDefenderHp: number; defeated: boolean; + coreResonance: boolean; }; type BondChainAttempt = { @@ -1335,6 +1341,7 @@ type BondChainAttempt = { rate: number; expectedDamage: number; succeeded: boolean; + coreResonance: boolean; }; type BondChainJudgementSnapshot = { @@ -1351,6 +1358,7 @@ type BondChainJudgementSnapshot = { completed: boolean; motion: 'melee' | 'ranged'; bounds: SortieOrderHudBounds | null; + coreResonance: boolean; }; type UnitBattleStats = { @@ -1550,6 +1558,7 @@ type CombatPreview = { bondChainPartnerId?: string; bondChainPartnerName?: string; bondChainLabel?: string; + bondChainCoreResonance: boolean; equipmentDamageBonus: number; equipmentDamageReduction: number; equipmentHitBonus: number; @@ -1660,6 +1669,7 @@ type BondChainReadyMarkerView = { damage: number; text: string; context: string; + coreResonance: boolean; background: Phaser.GameObjects.Rectangle; label: Phaser.GameObjects.Text; }; @@ -1673,6 +1683,7 @@ type BondChainReadyFocusedPreview = { locked: boolean; text: string; context: string; + coreResonance: boolean; background: Phaser.GameObjects.Rectangle; titleText: Phaser.GameObjects.Text; contextText: Phaser.GameObjects.Text; @@ -3407,6 +3418,7 @@ export class BattleScene extends Phaser.Scene { private resultGaugeAnimations: ResultGaugeAnimation[] = []; private resultAnimationToken = 0; private resultSettlementText?: Phaser.GameObjects.Text; + private resultCoreResonanceSummaryText?: Phaser.GameObjects.Text; private resultFormationReviewObjects: Phaser.GameObjects.GameObject[] = []; private resultFormationReviewVisible = false; private resultFormationReviewFeedback = ''; @@ -3467,6 +3479,11 @@ export class BattleScene extends Phaser.Scene { private launchSortieFormationAssignments: SortieFormationAssignments = {}; private launchSortieItemAssignments: CampaignSortieItemAssignments = {}; private launchSortieOrderId: SortieOrderId = 'elite'; + private launchSortieResonanceBondId?: string; + private coreResonancePursuitAttempts = 0; + private coreResonancePursuitSuccesses = 0; + private coreResonancePursuitFailures = 0; + private coreResonancePursuitDamage = 0; constructor() { super('BattleScene'); @@ -3481,10 +3498,17 @@ export class BattleScene extends Phaser.Scene { const selectedOrderId = campaign.sortieOrderSelection?.battleId === battleScenario.id ? campaign.sortieOrderSelection.orderId : undefined; + const selectedResonanceBondId = campaign.sortieResonanceSelection?.battleId === battleScenario.id + ? campaign.sortieResonanceSelection.bondId + : undefined; this.launchSortieUnitIds = this.normalizeLaunchSortieUnitIds(data?.selectedSortieUnitIds); this.launchSortieFormationAssignments = normalizeSortieFormationAssignments(data?.sortieFormationAssignments); this.launchSortieItemAssignments = this.normalizeLaunchSortieItemAssignments(data?.sortieItemAssignments); this.launchSortieOrderId = this.normalizeLaunchSortieOrderId(data?.sortieOrderId ?? selectedOrderId); + this.launchSortieResonanceBondId = this.normalizeLaunchSortieResonanceBondId( + data?.sortieResonanceBondId ?? selectedResonanceBondId + ); + this.resetCoreResonancePursuitStats(); } create() { @@ -3513,6 +3537,10 @@ export class BattleScene extends Phaser.Scene { false ); this.bondStates = this.createBondStates(); + this.launchSortieResonanceBondId = this.validatedLaunchSortieResonanceBondId( + this.launchSortieResonanceBondId + ); + this.resetCoreResonancePursuitStats(); this.itemStocks = this.createItemStocks(campaign); this.battleBuffs.clear(); this.battleStatuses.clear(); @@ -3958,6 +3986,52 @@ export class BattleScene extends Phaser.Scene { return orderId === 'mobile' || orderId === 'siege' ? orderId : 'elite'; } + private normalizeLaunchSortieResonanceBondId(bondId?: string) { + const normalized = bondId?.trim(); + return normalized || undefined; + } + + private validatedLaunchSortieResonanceBondId(bondId?: string) { + if (!bondId) { + return undefined; + } + const bond = this.bondStates.get(bondId); + if (!bond || bond.level < coreSortieResonanceMinLevel) { + return undefined; + } + const deployedAllies = new Set( + battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => unit.id) + ); + return bond.unitIds.every((unitId) => deployedAllies.has(unitId)) ? bond.id : undefined; + } + + private resetCoreResonancePursuitStats() { + this.coreResonancePursuitAttempts = 0; + this.coreResonancePursuitSuccesses = 0; + this.coreResonancePursuitFailures = 0; + this.coreResonancePursuitDamage = 0; + } + + private coreResonancePursuitStatsSnapshot(): BattleSaveCoreResonanceStats { + return { + attempts: this.coreResonancePursuitAttempts, + successes: this.coreResonancePursuitSuccesses, + failures: this.coreResonancePursuitFailures, + additionalDamage: this.coreResonancePursuitDamage + }; + } + + private restoreCoreResonancePursuitStats(stats?: BattleSaveCoreResonanceStats) { + if (!stats) { + this.resetCoreResonancePursuitStats(); + return; + } + this.coreResonancePursuitAttempts = stats.attempts; + this.coreResonancePursuitSuccesses = stats.successes; + this.coreResonancePursuitFailures = stats.failures; + this.coreResonancePursuitDamage = stats.additionalDamage; + } + private effectiveSortieUnitIds(campaign?: CampaignState) { return this.launchSortieUnitIds.length > 0 ? this.launchSortieUnitIds : campaign?.selectedSortieUnitIds ?? []; } @@ -4094,10 +4168,13 @@ export class BattleScene extends Phaser.Scene { if (this.firstPursuitTrinityConfigured()) { lines.push('도원 공명 · 세 형제 생존 중 공명 지원 거리 2칸'); } - const strongestBond = this.activeSortieBonds()[0]; + const activeBonds = this.activeSortieBonds(); + const strongestBond = activeBonds.find((bond) => this.isCoreSortieResonanceBond(bond)) ?? activeBonds[0]; if (strongestBond) { const bonus = sortieBondBonusForLevel(strongestBond.level); - lines.push(`공명 ${strongestBond.title} Lv${strongestBond.level} · 피해 +${bonus.damageBonus}%${bonus.chainRate > 0 ? ` · 연계 ${bonus.chainRate}%` : ''}`); + const chainRate = this.bondChainRateForBond(strongestBond); + const resonanceLabel = this.isCoreSortieResonanceBond(strongestBond) ? '핵심 공명' : '공명'; + lines.push(`${resonanceLabel} ${strongestBond.title} Lv${strongestBond.level} · 피해 +${bonus.damageBonus}%${chainRate > 0 ? ` · 연계 ${chainRate}%` : ''}`); } return lines; } @@ -9356,7 +9433,7 @@ export class BattleScene extends Phaser.Scene { icon: endsBattleWithPendingBonus ? 'failure' : this.previewDamageIcon(preview), name: target.name, meta: bondChainReady - ? `${getUnitClass(target.classKey).name} · 추격 후보 ${bondChainReady.partnerName} ${bondChainReady.rate}%` + ? `${getUnitClass(target.classKey).name} · ${bondChainReady.coreResonance ? '핵심 추격' : '추격 후보'} ${bondChainReady.partnerName} ${bondChainReady.rate}%` : `${getUnitClass(target.classKey).name} · 거리 ${distance}/${range}`, primary: `${preview.damage} 피해`, secondary: warningText ?? bondChainReady?.text ?? `${preview.hitRate}% ${successLabel} · ${counterLabel}`, @@ -9367,7 +9444,7 @@ export class BattleScene extends Phaser.Scene { endsBattleWithPendingBonus ? { icon: 'failure', label: '보조', value: '미확보', tone: 0xd8732c } : bondChainReady - ? { icon: 'leadership', label: '추격', value: `${bondChainReady.rate}% · +${bondChainReady.damage}`, tone: palette.gold } + ? { icon: 'leadership', label: bondChainReady.coreResonance ? '핵심' : '추격', value: `${bondChainReady.rate}% · +${bondChainReady.damage}`, tone: palette.gold } : { icon: 'counter', label: '반격', value: preview.counterAvailable ? '주의' : '없음', tone: preview.counterAvailable ? 0xd8732c : 0x59d18c } ] }; @@ -9386,7 +9463,11 @@ export class BattleScene extends Phaser.Scene { ], candidates, overflowCount: Math.max(0, targets.length - candidates.length), - footer: hasBondChainReady ? '금색 공명 · 명중 후 생존 시 확률 판정' : '표시된 적 선택 · 확정 전 예측 확인', + footer: hasBondChainReady + ? orderedTargets.some(({ bondChainReady }) => bondChainReady?.coreResonance) + ? '핵심 공명 우선 · 명중 후 생존 시 확률 판정' + : '금색 공명 · 명중 후 생존 시 확률 판정' + : '표시된 적 선택 · 확정 전 예측 확인', notice: notice ?? objectiveWarningNotice }); } @@ -10009,8 +10090,11 @@ export class BattleScene extends Phaser.Scene { partnerName: partner.name, rate: preview.bondChainRate, damage: preview.bondChainDamage, - text: `추격 후보 ${partner.name} ${preview.bondChainRate}% · 예상 +${preview.bondChainDamage}`, - context: '명중 후 대상 생존 시 · 지원 거리 충족' + text: `${preview.bondChainCoreResonance ? '핵심 추격' : '추격 후보'} ${partner.name} ${preview.bondChainRate}% · 예상 +${preview.bondChainDamage}`, + context: preview.bondChainCoreResonance + ? '핵심 공명 우선 · 명중 후 대상 생존 시' + : '명중 후 대상 생존 시 · 지원 거리 충족', + coreResonance: preview.bondChainCoreResonance }; } @@ -10087,7 +10171,7 @@ export class BattleScene extends Phaser.Scene { const label = this.add.text( this.tileTopLeftX(preview.defender.x) + offsetX - width / 2, this.tileTopLeftY(preview.defender.y) + offsetY + height / 2, - '공명', + ready.coreResonance ? '핵심' : '공명', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '10px', @@ -11477,6 +11561,23 @@ export class BattleScene extends Phaser.Scene { hiddenSummary.setDepth(depth + 2); } + if (this.launchSortieResonanceBondId) { + const coreResonanceSummary = this.trackResultObject(this.add.text( + left + panelWidth - 44, + top + 372, + `핵심 공명 · 시도 ${this.coreResonancePursuitAttempts} · 성공 ${this.coreResonancePursuitSuccesses} · 추가 피해 +${this.coreResonancePursuitDamage}`, + { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: '10px', + color: '#ffe79a', + fontStyle: '700' + } + )); + coreResonanceSummary.setOrigin(1, 0); + coreResonanceSummary.setDepth(depth + 3); + this.resultCoreResonanceSummaryText = coreResonanceSummary; + } + visibleAllies.forEach((unit, index) => { this.renderResultUnitRow(unit, left + 44, top + 386 + index * 70, panelWidth - 88, depth + 2); }); @@ -11546,7 +11647,11 @@ export class BattleScene extends Phaser.Scene { } this.addResultButton('다시 하기', left + panelWidth - 276, top + 612, 124, () => { soundDirector.playSelect(); - this.scene.restart({ battleId: battleScenario.id, sortieOrderId: this.launchSortieOrderId }); + this.scene.restart({ + battleId: battleScenario.id, + sortieOrderId: this.launchSortieOrderId, + sortieResonanceBondId: this.launchSortieResonanceBondId + }); }, depth + 3); this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => { soundDirector.playSelect(); @@ -11573,6 +11678,7 @@ export class BattleScene extends Phaser.Scene { this.resultObjects = []; this.resultGaugeAnimations = []; this.resultSettlementText = undefined; + this.resultCoreResonanceSummaryText = undefined; } private objectiveCategory(objective: BattleObjectiveDefinition): BattleObjectiveResult['category'] { @@ -13652,6 +13758,7 @@ export class BattleScene extends Phaser.Scene { bondChainPartnerId: bondChainCandidate?.partner.id, bondChainPartnerName: bondChainCandidate?.partner.name, bondChainLabel: bondChainCandidate?.label, + bondChainCoreResonance: bondChainCandidate?.coreResonance ?? false, equipmentDamageBonus: equipmentEffect.damageBonus, equipmentDamageReduction: equipmentEffect.damageReduction, equipmentHitBonus: equipmentEffect.hitBonus, @@ -13790,9 +13897,16 @@ export class BattleScene extends Phaser.Scene { partnerName: partner.name, rate: preview.bondChainRate, expectedDamage: preview.bondChainDamage, - succeeded + succeeded, + coreResonance: preview.bondChainCoreResonance }; + if (attempt.coreResonance) { + this.coreResonancePursuitAttempts += 1; + } if (!succeeded) { + if (attempt.coreResonance) { + this.coreResonancePursuitFailures += 1; + } return { attempt }; } @@ -13802,6 +13916,10 @@ export class BattleScene extends Phaser.Scene { const defeated = preview.defender.hp <= 0; this.recordBondChainStats(partner, preview.defender, damage, defeated); this.faceUnitToward(partner, preview.defender); + if (attempt.coreResonance) { + this.coreResonancePursuitSuccesses += 1; + this.coreResonancePursuitDamage += damage; + } return { attempt, @@ -13813,7 +13931,8 @@ export class BattleScene extends Phaser.Scene { rate: preview.bondChainRate, damage, previousDefenderHp, - defeated + defeated, + coreResonance: attempt.coreResonance } }; } @@ -14212,8 +14331,8 @@ export class BattleScene extends Phaser.Scene { const summaryBond = result.bondExp > 0 ? `\n공명 경험치 +${result.bondExp}` : ''; const summaryBondChain = result.bondChainAttempt ? result.bondChain - ? `\n공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 일치\n공명 연계 · ${result.bondChain.partnerName} 추격 피해 +${result.bondChain.damage}${result.bondChain.defeated ? ' · 공명 격파' : ''}` - : `\n공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 불발` + ? `\n${result.bondChainAttempt.coreResonance ? '핵심 공명' : '공명'} 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 일치\n${result.bondChain.coreResonance ? '핵심 공명' : '공명'} 연계 · ${result.bondChain.partnerName} 추격 피해 +${result.bondChain.damage}${result.bondChain.defeated ? ' · 공명 격파' : ''}` + : `\n${result.bondChainAttempt.coreResonance ? '핵심 공명' : '공명'} 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 불발` : ''; const summaryCounter = result.counter ? `\n${this.formatCounterLine(result.counter)}` : ''; return `${headline}\n${resolution}${summaryBondChain}${summaryCounter}${summaryBondBonus}${summarySortie}${summaryInitiative}${summaryEquipment}\n${this.formatCharacterGrowth(result.characterGrowth)}\n${this.formatEquipmentGrowth(result.attackerGrowth)}\n${this.formatEquipmentGrowth(result.defenderGrowth)}${summaryBond}`; @@ -14246,7 +14365,7 @@ export class BattleScene extends Phaser.Scene { private bondChainMapCueLabel(result: CombatResult) { if (result.bondChainAttempt) { - return `공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}%`; + return `${result.bondChainAttempt.coreResonance ? '핵심 공명' : '공명'} 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}%`; } if (result.bondExtendedRange) { return '삼재진 · 2칸 공명'; @@ -15569,6 +15688,13 @@ export class BattleScene extends Phaser.Scene { state.sortieOrderId ?? ( campaign.sortieOrderSelection?.battleId === battleScenario.id ? campaign.sortieOrderSelection.orderId + : undefined + ) + ); + this.launchSortieResonanceBondId = this.normalizeLaunchSortieResonanceBondId( + state.sortieResonanceBondId ?? ( + campaign.sortieResonanceSelection?.battleId === battleScenario.id + ? campaign.sortieResonanceSelection.bondId : undefined ) ); @@ -15585,6 +15711,10 @@ export class BattleScene extends Phaser.Scene { battleId: battleScenario.id, campaignStep: getCampaignState().step, sortieOrderId: this.launchSortieOrderId, + sortieResonanceBondId: this.launchSortieResonanceBondId, + coreResonanceStats: this.launchSortieResonanceBondId + ? { ...this.coreResonancePursuitStatsSnapshot() } + : undefined, savedAt: new Date().toISOString(), turnNumber: this.turnNumber, activeFaction: this.activeFaction, @@ -15641,6 +15771,10 @@ export class BattleScene extends Phaser.Scene { this.phase = 'idle'; this.launchSortieOrderId = this.normalizeLaunchSortieOrderId(state.sortieOrderId ?? this.launchSortieOrderId); + this.launchSortieResonanceBondId = this.normalizeLaunchSortieResonanceBondId( + state.sortieResonanceBondId ?? this.launchSortieResonanceBondId + ); + this.resetCoreResonancePursuitStats(); this.turnNumber = Math.max(1, state.turnNumber || 1); this.activeFaction = state.activeFaction === 'enemy' ? 'enemy' : 'ally'; this.rosterTab = state.rosterTab === 'enemy' ? 'enemy' : 'ally'; @@ -15692,6 +15826,13 @@ export class BattleScene extends Phaser.Scene { this.restoreUnitView(unit, savedUnit.direction); }); + this.launchSortieResonanceBondId = this.validatedLaunchSortieResonanceBondId( + this.launchSortieResonanceBondId + ); + if (this.launchSortieResonanceBondId) { + this.restoreCoreResonancePursuitStats(state.coreResonanceStats); + } + const cameraFocus = battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0) ?? battleUnits[0]; if (cameraFocus) { this.centerCameraOnTile(cameraFocus.x, cameraFocus.y); @@ -16795,7 +16936,11 @@ export class BattleScene extends Phaser.Scene { return { icon: 'counter', label: '퇴각', tone: 0xd8732c }; } if (result.bondChainAttempt) { - return { icon: 'leadership', label: `판정 ${result.bondChainAttempt.rate}%`, tone: palette.gold }; + return { + icon: 'leadership', + label: `${result.bondChainAttempt.coreResonance ? '핵심 ' : ''}판정 ${result.bondChainAttempt.rate}%`, + tone: palette.gold + }; } if (this.combatCounterWillTrigger(result)) { return { icon: 'counter', label: '반격 예정', tone: 0xb64a45 }; @@ -18542,7 +18687,7 @@ export class BattleScene extends Phaser.Scene { line.strokePath(); } const partnerLabel = result.bondChainAttempt - ? `${partner.name} · 판정 후보` + ? `${partner.name} · ${result.bondChainAttempt.coreResonance ? '핵심 판정' : '판정 후보'}` : result.bondExtendedRange ? `${partner.name} · 2칸` : `${partner.name} ♥ 공명`; @@ -18645,7 +18790,7 @@ export class BattleScene extends Phaser.Scene { const width = 372; const height = 66; const tone = succeeded ? palette.gold : 0xa58f69; - const title = `공명 판정 ${attempt.rate}%`; + const title = `${attempt.coreResonance ? '핵심 공명' : '공명'} 판정 ${attempt.rate}%`; const detail = `${attempt.partnerName} · ${succeeded ? '호흡 일치' : '호흡 불발'}`; const card = this.trackCombatObject(this.add.container(textX, textY)); card.setDepth(depth + 4); @@ -18687,7 +18832,8 @@ export class BattleScene extends Phaser.Scene { presented: true, completed: false, motion, - bounds: { x: textX - width / 2, y: textY - height / 2, width, height } + bounds: { x: textX - width / 2, y: textY - height / 2, width, height }, + coreResonance: attempt.coreResonance }; soundDirector.playEffect('bond-resonance', { @@ -18762,7 +18908,7 @@ export class BattleScene extends Phaser.Scene { const bannerIconFrame = this.add.circle(-bannerWidth / 2 + 39, 0, 22, 0x0a0f14, 0.96); bannerIconFrame.setStrokeStyle(1, palette.gold, 0.82); const bannerIcon = this.createBattleUiIcon(-bannerWidth / 2 + 39, 0, 'leadership', 39); - const bannerTitle = this.add.text(-bannerWidth / 2 + 72, -23, `공명 추격 · ${chain.partnerName}`, { + const bannerTitle = this.add.text(-bannerWidth / 2 + 72, -23, `${chain.coreResonance ? '핵심 공명' : '공명'} 추격 · ${chain.partnerName}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: '20px', color: '#fff2b8', @@ -18828,7 +18974,8 @@ export class BattleScene extends Phaser.Scene { onImpact?.(); this.showCombatImpact(followUpResult, defenderX, groundY - 46, depth + 5); - const finishTitle = chain.defeated ? `공명 격파 · ${chain.partnerName}` : `공명 추격 · ${chain.partnerName}`; + const chainTitle = chain.coreResonance ? '핵심 공명' : '공명'; + const finishTitle = chain.defeated ? `${chainTitle} 격파 · ${chain.partnerName}` : `${chainTitle} 추격 · ${chain.partnerName}`; const finishDetail = `추격 -${chain.damage}`; bannerTitle.setText(finishTitle); bannerDetail.setText(finishDetail); @@ -18849,7 +18996,7 @@ export class BattleScene extends Phaser.Scene { const damageText = this.trackCombatObject(this.add.text( defenderX - 12, groundY - 112, - chain.defeated ? `공명 격파\n추격 -${chain.damage}` : `추격 -${chain.damage}`, + chain.defeated ? `${chain.coreResonance ? '핵심 격파' : '공명 격파'}\n추격 -${chain.damage}` : `${chain.coreResonance ? '핵심 ' : ''}추격 -${chain.damage}`, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: chain.defeated ? '26px' : '23px', @@ -19720,6 +19867,16 @@ export class BattleScene extends Phaser.Scene { }); } + private isCoreSortieResonanceBond(bond: Pick) { + return bond.id === this.launchSortieResonanceBondId && bond.level >= coreSortieResonanceMinLevel; + } + + private bondChainRateForBond(bond: Pick) { + return this.isCoreSortieResonanceBond(bond) + ? coreSortieResonanceChainRateForLevel(bond.level) + : sortieBondBonusForLevel(bond.level).chainRate; + } + private bondChainPartnerEligible( attacker: UnitData, partner: UnitData, @@ -19739,7 +19896,7 @@ export class BattleScene extends Phaser.Scene { attacker.hp <= 0 || partner.hp <= 0 || defender.hp <= 0 || - sortieBondBonusForLevel(bond.level).chainRate <= 0 || + this.bondChainRateForBond(bond) <= 0 || !actedUnitIds.has(partner.id) || !intents.some((intent) => intent.attackerId === partner.id && intent.targetId === defender.id) ) { @@ -19769,10 +19926,14 @@ export class BattleScene extends Phaser.Scene { if (!partner || !this.bondChainPartnerEligible(attacker, partner, defender, bond, action, isCounter)) { return undefined; } - return { bond, partner }; + return { bond, partner, coreResonance: this.isCoreSortieResonanceBond(bond) }; }) - .filter((candidate): candidate is { bond: BondState; partner: UnitData } => Boolean(candidate)) - .sort((left, right) => right.bond.level - left.bond.level || left.bond.id.localeCompare(right.bond.id)); + .filter((candidate): candidate is { bond: BondState; partner: UnitData; coreResonance: boolean } => Boolean(candidate)) + .sort((left, right) => ( + Number(right.coreResonance) - Number(left.coreResonance) || + right.bond.level - left.bond.level || + left.bond.id.localeCompare(right.bond.id) + )); const strongest = candidates[0]; if (!strongest) { @@ -19782,10 +19943,11 @@ export class BattleScene extends Phaser.Scene { const [first, second] = strongest.bond.unitIds.map((id) => this.unitName(id)); return { bondId: strongest.bond.id, - label: `${strongest.bond.title}: ${first}·${second}`, + label: `${strongest.coreResonance ? '핵심 공명 · ' : ''}${strongest.bond.title}: ${first}·${second}`, partner: strongest.partner, - rate: sortieBondBonusForLevel(strongest.bond.level).chainRate, - damage: this.bondChainSupportDamage(strongest.partner, defender) + rate: this.bondChainRateForBond(strongest.bond), + damage: this.bondChainSupportDamage(strongest.partner, defender), + coreResonance: strongest.coreResonance } satisfies BondChainCandidate; } @@ -20764,9 +20926,9 @@ export class BattleScene extends Phaser.Scene { const effectLine = [ result.bondChain - ? `${result.bondChain.defeated ? '공명 격파' : '공명 추격'} ${result.bondChain.partnerName} +${result.bondChain.damage}` + ? `${result.bondChain.coreResonance ? '핵심 ' : '공명 '}${result.bondChain.defeated ? '격파' : '추격'} ${result.bondChain.partnerName} +${result.bondChain.damage}` : result.bondChainAttempt - ? `공명 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 불발` + ? `${result.bondChainAttempt.coreResonance ? '핵심 공명' : '공명'} 판정 · ${result.bondChainAttempt.partnerName} ${result.bondChainAttempt.rate}% · 호흡 불발` : '', this.statusEffectMapText(result.statusApplied), this.combatSortieContributionText(result), @@ -20793,6 +20955,9 @@ export class BattleScene extends Phaser.Scene { private combatMapDamageTitle(result: CombatResult) { if (result.bondChain) { const totalDamage = result.damage + result.bondChain.damage; + if (result.bondChain.coreResonance) { + return result.bondChain.defeated ? `핵심 공명 격파 -${totalDamage}` : `핵심 합동 피해 -${totalDamage}`; + } return result.bondChain.defeated ? `공명 격파 -${totalDamage}` : `합동 피해 -${totalDamage}`; } const damage = `-${result.damage}`; @@ -22622,6 +22787,7 @@ export class BattleScene extends Phaser.Scene { partnerName: view.partnerName, rate: view.rate, damage: view.damage, + coreResonance: view.coreResonance, text: view.text, context: view.context, targetMarkerId: view.background.name, @@ -22652,6 +22818,7 @@ export class BattleScene extends Phaser.Scene { partnerName: this.bondChainReadyFocusedPreview.partnerName, rate: this.bondChainReadyFocusedPreview.rate, damage: this.bondChainReadyFocusedPreview.damage, + coreResonance: this.bondChainReadyFocusedPreview.coreResonance, locked: this.bondChainReadyFocusedPreview.locked, text: this.bondChainReadyFocusedPreview.text, context: this.bondChainReadyFocusedPreview.context, @@ -22673,6 +22840,43 @@ export class BattleScene extends Phaser.Scene { }; } + private coreSortieResonanceDebugState() { + const selectedBondId = this.launchSortieResonanceBondId; + const bond = selectedBondId ? this.bondStates.get(selectedBondId) : undefined; + const active = Boolean( + bond && + this.isCoreSortieResonanceBond(bond) && + bond.unitIds.every((unitId) => ( + battleUnits.some((unit) => unit.id === unitId && unit.faction === 'ally' && unit.hp > 0) + )) + ); + return { + selected: Boolean(selectedBondId), + selectedBondId: selectedBondId ?? null, + valid: Boolean(bond), + active, + bondId: bond?.id ?? null, + title: bond?.title ?? null, + unitIds: bond ? [...bond.unitIds] : [], + unitNames: bond ? bond.unitIds.map((unitId) => this.unitName(unitId)) : [], + level: bond?.level ?? null, + rate: bond ? this.bondChainRateForBond(bond) : 0, + stats: { + attempts: this.coreResonancePursuitAttempts, + successes: this.coreResonancePursuitSuccesses, + failures: this.coreResonancePursuitFailures, + additionalDamage: this.coreResonancePursuitDamage + }, + resultSummary: this.resultCoreResonanceSummaryText?.active + ? { + visible: this.resultCoreResonanceSummaryText.visible, + text: this.resultCoreResonanceSummaryText.text, + bounds: this.gameObjectBoundsDebug(this.resultCoreResonanceSummaryText) + } + : null + }; + } + private bondChainJudgementDebugState() { return this.bondChainJudgementLast ? { @@ -22839,6 +23043,7 @@ export class BattleScene extends Phaser.Scene { ? this.resultFormationSourcePresetIds(resultFormationReview.snapshot) : []; const sortieOrderDebug = this.resultSortieOrderDebugState(); + const sortieResonanceDebug = this.coreSortieResonanceDebugState(); return { scene: this.scene.key, @@ -22851,6 +23056,8 @@ export class BattleScene extends Phaser.Scene { sortieItemAssignments, sortieOrder: sortieOrderDebug, sortieOperationOrder: sortieOrderDebug, + sortieResonance: sortieResonanceDebug, + coreSortieResonance: sortieResonanceDebug, battleHud: this.battleHudDebugState(), sortieDoctrine: this.debugSortieDoctrine(), firstSortieRoleEffects: this.debugFirstPursuitRoleEffects(), @@ -23132,7 +23339,7 @@ export class BattleScene extends Phaser.Scene { private debugCombatMechanicsProbe() { const bond = Array.from(this.bondStates.values()).find((candidate) => { const units = candidate.unitIds.map((unitId) => battleUnits.find((unit) => unit.id === unitId && unit.hp > 0)); - return units.length === 2 && units.every((unit) => unit?.faction === 'ally') && sortieBondBonusForLevel(candidate.level).chainRate > 0; + return units.length === 2 && units.every((unit) => unit?.faction === 'ally') && this.bondChainRateForBond(candidate) > 0; }); const attacker = bond ? battleUnits.find((unit) => unit.id === bond.unitIds[0] && unit.hp > 0) : undefined; const partner = bond ? battleUnits.find((unit) => unit.id === bond.unitIds[1] && unit.hp > 0) : undefined; @@ -23164,11 +23371,11 @@ export class BattleScene extends Phaser.Scene { const counterIncomingPreview = this.combatPreview(enemy, attacker, 'attack', undefined, true); const matchingIntent = [{ attackerId: syntheticPartner.id, targetId: syntheticEnemy.id }]; const syntheticActedUnitIds = new Set([syntheticPartner.id]); - const chainBonus = sortieBondBonusForLevel(bond.level); + const chainRate = this.bondChainRateForBond(bond); const supportDamage = this.bondChainSupportDamage(partner, enemy); const chainGatePreview: CombatPreview = { ...adjacentPreview, - bondChainRate: chainBonus.chainRate, + bondChainRate: chainRate, bondChainDamage: supportDamage, bondChainBondId: bond.id, bondChainPartnerId: syntheticPartner.id, @@ -23191,7 +23398,8 @@ export class BattleScene extends Phaser.Scene { normalDamage: normalIncomingPreview.damage, counterDamage: counterIncomingPreview.damage, chain: { - rate: chainBonus.chainRate, + rate: chainRate, + coreResonance: this.isCoreSortieResonanceBond(bond), expectedSupporterId: syntheticPartner.id, supportDamage, ineligibleWithoutPriorIntent: !this.bondChainPartnerEligible( @@ -23237,8 +23445,8 @@ export class BattleScene extends Phaser.Scene { missedBaseBlocked: !this.canResolveBondChain(chainGatePreview, false, syntheticEnemy.hp), lethalBaseBlocked: !this.canResolveBondChain(chainGatePreview, true, 0), survivingBaseAllowed: this.canResolveBondChain(chainGatePreview, true, syntheticEnemy.hp), - forcedSuccessTriggers: this.bondChainRollSucceeded(chainBonus.chainRate, true), - forcedFailureBlocked: !this.bondChainRollSucceeded(chainBonus.chainRate, false), + forcedSuccessTriggers: this.bondChainRollSucceeded(chainRate, true), + forcedFailureBlocked: !this.bondChainRollSucceeded(chainRate, false), statCredit, followUpKillCancelsCounter: !this.canCounterAttack( { ...syntheticEnemy, hp: 0 }, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index c23cac6..dadf96f 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -32,6 +32,8 @@ import { } from '../data/sortieDeployment'; import { compareSortieSynergy, + coreSortieResonanceChainRateForLevel, + coreSortieResonanceMinLevel, coreSortieSynergyRoles, evaluateSortiePursuitPairs, evaluateSortieSynergy, @@ -146,6 +148,7 @@ import { listCampaignSaveSlots, campaignSaveSlotCount, saveCampaignState, + setCampaignSortieResonanceSelection, setCampaignSortieOrderSelection, setCampaignReserveTrainingAssignment, setCampaignReserveTrainingFocus, @@ -414,14 +417,32 @@ type SortiePursuitPanelView = { background: Phaser.GameObjects.Rectangle; summaryText: Phaser.GameObjects.Text; ruleText: Phaser.GameObjects.Text; + page: number; + pageCount: number; + prevEnabled: boolean; + nextEnabled: boolean; + prevButton: Phaser.GameObjects.Rectangle; + prevLabel: Phaser.GameObjects.Text; + nextButton: Phaser.GameObjects.Rectangle; + nextLabel: Phaser.GameObjects.Text; + pageText: Phaser.GameObjects.Text; rows: { - state: 'candidate'; + bondId: string; + level: number; + chainRate: number; + selected: boolean; + state: 'selected' | 'candidate'; text: string; background: Phaser.GameObjects.Rectangle; label: Phaser.GameObjects.Text; }[]; }; +type SortieCoreResonanceCandidate = SortieSynergyActiveBond & { + coreChainRate: number; + selected: boolean; +}; + type SortieConfigurationSnapshot = { selectedUnitIds: string[]; formationAssignments: SortieFormationAssignments; @@ -11038,6 +11059,10 @@ export class CampScene extends Phaser.Scene { private sortieOrderToggleButton?: Phaser.GameObjects.Rectangle; private sortieComparisonPanelView?: SortieComparisonPanelView; private sortiePursuitPanelView?: SortiePursuitPanelView; + private sortieCoreResonancePage = 0; + private sortieCoreResonanceSelectorOpen = false; + private sortieCoreResonanceToggleButton?: Phaser.GameObjects.Rectangle; + private sortieCoreResonanceToggleLabel?: Phaser.GameObjects.Text; private sortieRosterScroll = 0; private sortiePlanFeedback = ''; private sortiePrepStep: SortiePrepStep = 'briefing'; @@ -11060,6 +11085,10 @@ export class CampScene extends Phaser.Scene { this.sortieObjects = []; this.sortieComparisonPanelView = undefined; this.sortiePursuitPanelView = undefined; + this.sortieCoreResonancePage = 0; + this.sortieCoreResonanceSelectorOpen = false; + this.sortieCoreResonanceToggleButton = undefined; + this.sortieCoreResonanceToggleLabel = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; this.sortieSwapUndoState = undefined; this.sortieFormationPanelMode = 'roster'; @@ -12787,6 +12816,10 @@ export class CampScene extends Phaser.Scene { return; } this.sortiePrepStep = step; + if (step !== 'formation') { + this.sortieCoreResonanceSelectorOpen = false; + this.sortieCoreResonancePage = 0; + } this.sortiePinnedSwapCandidateUnitId = undefined; this.sortieHoveredUnitId = undefined; this.closeSortieFormationPanelBrowser(); @@ -13449,6 +13482,30 @@ export class CampScene extends Phaser.Scene { const summary = this.trackSortie(this.add.text(x + width - 18, y + 11, '', this.textStyle(9, '#d8b15f', true))); summary.setOrigin(1, 0); summary.setDepth(depth + 1); + const toggleWidth = 96; + const toggleX = x + Math.floor(width / 2) - Math.floor(toggleWidth / 2); + const toggleFill = this.sortieCoreResonanceSelectorOpen ? 0x304333 : 0x18232e; + const toggleStroke = this.sortieCoreResonanceSelectorOpen ? palette.green : palette.blue; + const toggleButton = this.trackSortie(this.add.rectangle(toggleX, y + 6, toggleWidth, 19, toggleFill, 0.98)); + toggleButton.setOrigin(0); + toggleButton.setDepth(depth + 2); + toggleButton.setStrokeStyle(1, toggleStroke, this.sortieCoreResonanceSelectorOpen ? 0.88 : 0.64); + toggleButton.setInteractive({ useHandCursor: true }); + toggleButton.on('pointerover', () => toggleButton.setFillStyle(this.sortieCoreResonanceSelectorOpen ? 0x3a5540 : 0x263442, 1).setStrokeStyle(1, palette.gold, 0.96)); + toggleButton.on('pointerout', () => toggleButton.setFillStyle(toggleFill, 0.98).setStrokeStyle(1, toggleStroke, this.sortieCoreResonanceSelectorOpen ? 0.88 : 0.64)); + toggleButton.on('pointerdown', () => this.toggleCampaignSortieCoreResonanceSelector()); + const toggleLabel = this.trackSortie( + this.add.text( + toggleX + toggleWidth / 2, + y + 15.5, + this.sortieCoreResonanceSelectorOpen ? '편성 비교' : '핵심 공명조', + this.textStyle(9, this.sortieCoreResonanceSelectorOpen ? '#e7ffd9' : '#d8ecff', true) + ) + ); + toggleLabel.setOrigin(0.5); + toggleLabel.setDepth(depth + 3); + this.sortieCoreResonanceToggleButton = toggleButton; + this.sortieCoreResonanceToggleLabel = toggleLabel; const headline = this.trackSortie(this.add.text(x + 18, y + 31, '', this.textStyle(11, '#c8d2dd', true))); headline.setDepth(depth + 1); const actionButton = this.trackSortie(this.add.rectangle(x + width - 108, y + 27, 90, 20, 0x263a2d, 0.98)); @@ -13497,6 +13554,16 @@ export class CampScene extends Phaser.Scene { impact.setDepth(depth + 1); const detail = this.trackSortie(this.add.text(x + 18, y + 106, '', this.textStyle(9, '#9fb0bf'))); detail.setDepth(depth + 1); + const coreCandidates = this.sortieCoreResonanceCandidates(); + const pursuitPageView = this.renderSortieCoreResonanceChips( + coreCandidates, + x + 18, + y + 51, + width - 36, + depth + 2, + x + width - 84, + y + 104 + ); this.sortieComparisonPanelView = { background: bg, @@ -13509,17 +13576,66 @@ export class CampScene extends Phaser.Scene { actionButton, actionLabel }; + this.sortiePursuitPanelView = { + background: bg, + summaryText: title, + ruleText: detail, + ...pursuitPageView + }; this.refreshCampaignSortieComparisonPanel(); } + private toggleCampaignSortieCoreResonanceSelector() { + this.sortieCoreResonanceSelectorOpen = !this.sortieCoreResonanceSelectorOpen; + if (this.sortieCoreResonanceSelectorOpen) { + this.closeSortieFormationPanelBrowser(); + this.sortieHoveredUnitId = undefined; + this.sortiePinnedSwapCandidateUnitId = undefined; + } + soundDirector.playSelect(); + this.showSortiePrep(); + } + + private setCampaignSortieCoreSelectorVisible(visible: boolean) { + const pursuitView = this.sortiePursuitPanelView; + pursuitView?.rows.forEach((row) => { + row.background.setVisible(visible); + row.label.setVisible(visible); + if (visible) { + row.background.setInteractive({ useHandCursor: true }); + } else { + row.background.disableInteractive(); + } + }); + if (pursuitView) { + [pursuitView.prevButton, pursuitView.prevLabel, pursuitView.nextButton, pursuitView.nextLabel, pursuitView.pageText] + .forEach((object) => object.setVisible(visible)); + if (visible && pursuitView.prevEnabled) { + pursuitView.prevButton.setInteractive({ useHandCursor: true }); + } else { + pursuitView.prevButton.disableInteractive(); + } + if (visible && pursuitView.nextEnabled) { + pursuitView.nextButton.setInteractive({ useHandCursor: true }); + } else { + pursuitView.nextButton.disableInteractive(); + } + } + this.sortieComparisonPanelView?.metrics.forEach((metric) => { + metric.background.setVisible(!visible); + metric.label.setVisible(!visible); + metric.value.setVisible(!visible); + }); + } + private refreshCampaignSortieComparisonPanel() { const view = this.sortieComparisonPanelView; if (!view) { return; } - const preview = this.sortieFormationComparisonPreview(); - const action = this.sortieComparisonAction(preview); + const preview = this.sortieCoreResonanceSelectorOpen ? undefined : this.sortieFormationComparisonPreview(); + const action = this.sortieCoreResonanceSelectorOpen ? undefined : this.sortieComparisonAction(preview); this.refreshSortieComparisonActionButton(view, action); const isUndoAction = action?.kind === 'undo-swap' || action?.kind === 'undo-preset'; const current = preview?.current ?? this.sortieSynergySnapshot(); @@ -13527,6 +13643,7 @@ export class CampScene extends Phaser.Scene { const showMetricTransition = !isUndoAction && Boolean(preview?.projected); const comparison = isUndoAction ? undefined : preview?.comparison; const pursuitChanges = this.changedSortiePursuitPairs(current, projected); + this.setCampaignSortieCoreSelectorVisible(false); 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); @@ -13537,6 +13654,32 @@ export class CampScene extends Phaser.Scene { lostRole ? 0.78 : isHoverPreview ? 0.72 : 0.58 ); + if (this.sortieCoreResonanceSelectorOpen) { + const coreSnapshot = this.sortieSynergySnapshot(); + const coreCandidates = this.sortieCoreResonanceCandidates(coreSnapshot); + const selectedCore = coreCandidates.find((candidate) => candidate.selected); + this.setCampaignSortieCoreSelectorVisible(true); + view.background.setStrokeStyle(selectedCore ? 2 : 1, selectedCore ? palette.green : palette.gold, selectedCore ? 0.78 : 0.62); + view.title.setText('핵심 공명조'); + view.summary.setText(selectedCore ? `지정 ${selectedCore.coreChainRate}% · 후보 ${coreCandidates.length}조` : `미지정 · 후보 ${coreCandidates.length}조`); + view.headline + .setText( + selectedCore + ? `${selectedCore.unitNames[0]}↔${selectedCore.unitNames[1]} · Lv${selectedCore.level} 핵심 추격 준비` + : coreCandidates.length > 0 + ? '아래 인연 칩을 눌러 이번 전투의 핵심 한 조를 정하십시오.' + : `Lv${coreSortieResonanceMinLevel} 이상 인연 조원을 함께 편성하면 후보가 열립니다.` + ) + .setColor(selectedCore ? '#a8ffd0' : coreCandidates.length > 0 ? '#ffdf7b' : '#9fb0bf'); + view.impact + .setText(this.compactText(this.sortiePlanFeedback || (selectedCore ? '다시 누르면 지정을 해제합니다.' : '정확히 한 조만 지정되며 다른 조를 누르면 즉시 교체됩니다.'), 68)) + .setColor(this.sortiePlanFeedback || selectedCore ? '#a8ffd0' : '#d4dce6'); + view.detail + .setText(`Lv${coreSortieResonanceMinLevel}+ · 핵심 추격 8/18/28% · 일반 추격 확률은 그대로 유지`) + .setColor('#9fb0bf'); + return; + } + if (!preview) { if (this.sortieOrderBrowserOpen) { const order = this.currentSortieOrderDefinition(); @@ -13577,11 +13720,11 @@ export class CampScene extends Phaser.Scene { : preview.source === 'hover-add' ? `가상 합류 · ${preview.incomingUnitName}` : `편성 변화 · ${this.unitName(preview.unitId)}`; - view.title.setText(this.compactText(title, 28)); + view.title.setText(this.compactText(title, 18)); view.summary.setText( this.compactText( `추격 후보 ${pursuitChanges.currentPairs.length}→${pursuitChanges.projectedPairs.length}조 · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3 · 지형 ${projected.terrainGrade}`, - 38 + 26 ) ); const headlineColor = isUndoAction || preview.source === 'hover-swap' || preview.source === 'pinned-swap' || @@ -13980,48 +14123,145 @@ export class CampScene extends Phaser.Scene { this.showSortiePrep(); } + private renderSortieCoreResonanceChips( + candidates: readonly SortieCoreResonanceCandidate[], + x: number, + y: number, + width: number, + depth: number, + pagerX: number, + pagerY: number + ): Pick< + SortiePursuitPanelView, + 'rows' | 'page' | 'pageCount' | 'prevEnabled' | 'nextEnabled' | 'prevButton' | 'prevLabel' | 'nextButton' | 'nextLabel' | 'pageText' + > { + const pageCount = Math.max(1, Math.ceil(candidates.length / 3)); + this.sortieCoreResonancePage = Phaser.Math.Clamp(this.sortieCoreResonancePage, 0, pageCount - 1); + const page = this.sortieCoreResonancePage; + const visibleCandidates = candidates.slice(page * 3, page * 3 + 3); + const chipGap = 6; + const availableChipWidth = Math.floor( + (width - chipGap * Math.max(0, visibleCandidates.length - 1)) / Math.max(1, visibleCandidates.length) + ); + const chipWidth = Math.min(148, availableChipWidth); + const rows: SortiePursuitPanelView['rows'] = visibleCandidates.map((candidate, index) => { + const chipX = x + index * (chipWidth + chipGap); + const selected = candidate.selected; + const chipText = `${selected ? '핵심' : '지정'} ${candidate.coreChainRate}% ${candidate.unitNames[0]}↔${candidate.unitNames[1]}`; + const fill = selected ? 0x493719 : 0x173329; + const stroke = selected ? palette.gold : palette.green; + const background = this.trackSortie(this.add.rectangle(chipX, y, chipWidth, 22, fill, 0.98)); + background.setOrigin(0); + background.setDepth(depth); + background.setStrokeStyle(selected ? 2 : 1, stroke, selected ? 0.96 : 0.68); + background.setInteractive({ useHandCursor: true }); + background.on('pointerover', () => { + background.setFillStyle(selected ? 0x604b25 : 0x244b3d, 1).setStrokeStyle(2, palette.gold, 0.98); + }); + background.on('pointerout', () => { + background.setFillStyle(fill, 0.98).setStrokeStyle(selected ? 2 : 1, stroke, selected ? 0.96 : 0.68); + }); + background.on('pointerdown', () => this.toggleSortieCoreResonance(candidate.id)); + const label = this.trackSortie( + this.add.text( + chipX + chipWidth / 2, + y + 11, + this.compactText(chipText, chipWidth >= 140 ? 18 : 15), + this.textStyle(9, selected ? '#ffdf7b' : '#a8ffd0', true) + ) + ); + label.setOrigin(0.5); + label.setDepth(depth + 1); + return { + bondId: candidate.id, + level: candidate.level, + chainRate: candidate.coreChainRate, + selected, + state: selected ? 'selected' as const : 'candidate' as const, + text: chipText, + background, + label + }; + }); + + const prevEnabled = page > 0; + const nextEnabled = page + 1 < pageCount; + const prevButton = this.trackSortie(this.add.rectangle(pagerX, pagerY, 18, 16, prevEnabled ? 0x263a2d : 0x111820, prevEnabled ? 0.98 : 0.62)); + prevButton.setOrigin(0); + prevButton.setDepth(depth); + prevButton.setStrokeStyle(1, prevEnabled ? palette.green : 0x53606c, prevEnabled ? 0.72 : 0.3); + if (prevEnabled) { + prevButton.setInteractive({ useHandCursor: true }); + prevButton.on('pointerdown', () => this.changeSortieCoreResonancePage(-1)); + } + const prevLabel = this.trackSortie(this.add.text(pagerX + 9, pagerY + 8, '◀', this.textStyle(7, prevEnabled ? '#e7ffd9' : '#77818c', true))); + prevLabel.setOrigin(0.5); + prevLabel.setDepth(depth + 1); + const pageText = this.trackSortie(this.add.text(pagerX + 34, pagerY + 8, `${page + 1}/${pageCount}`, this.textStyle(8, '#d8b15f', true))); + pageText.setOrigin(0.5); + pageText.setDepth(depth + 1); + const nextButton = this.trackSortie(this.add.rectangle(pagerX + 50, pagerY, 18, 16, nextEnabled ? 0x263a2d : 0x111820, nextEnabled ? 0.98 : 0.62)); + nextButton.setOrigin(0); + nextButton.setDepth(depth); + nextButton.setStrokeStyle(1, nextEnabled ? palette.green : 0x53606c, nextEnabled ? 0.72 : 0.3); + if (nextEnabled) { + nextButton.setInteractive({ useHandCursor: true }); + nextButton.on('pointerdown', () => this.changeSortieCoreResonancePage(1)); + } + const nextLabel = this.trackSortie(this.add.text(pagerX + 59, pagerY + 8, '▶', this.textStyle(7, nextEnabled ? '#e7ffd9' : '#77818c', true))); + nextLabel.setOrigin(0.5); + nextLabel.setDepth(depth + 1); + return { + rows, + page, + pageCount, + prevEnabled, + nextEnabled, + prevButton, + prevLabel, + nextButton, + nextLabel, + pageText + }; + } + private renderFirstSortieSynergyPanel(x: number, y: number, width: number, height: number, depth: number) { const synergy = this.sortieSynergySnapshot(); - const pursuit = this.sortiePursuitPairs(); + const coreCandidates = this.sortieCoreResonanceCandidates(synergy); + const selectedCore = coreCandidates.find((candidate) => candidate.selected); 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); + bg.setStrokeStyle(selectedCore ? 2 : 1, selectedCore ? palette.green : palette.gold, selectedCore ? 0.78 : 0.62); const summaryText = this.trackSortie( this.add.text( x + 18, y + 9, - `조합 시너지 · 추격 후보 ${pursuit.activePairs.length}조 · 역할 ${synergy.coveredRoleCount}/3`, - this.textStyle(15, '#f2e3bf', true) + `핵심 공명조 · ${selectedCore ? '지정' : '미지정'} · 후보 ${coreCandidates.length}조`, + this.textStyle(14, '#f2e3bf', true) ) ); 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); + this.trackSortie( + this.add.text(x + width - 18, y + 12, `역할 ${synergy.coveredRoleCount}/3 · 지형 ${synergy.terrainGrade}`, this.textStyle(9, '#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 pursuitPageView = this.renderSortieCoreResonanceChips( + coreCandidates, + x + 18, + y + 34, + width - 36, + depth + 1, + x + width - 84, + y + 60 + ); + if (pursuitPageView.rows.length === 0) { + this.trackSortie( + this.add.text(x + 18, y + 38, `후보 없음 · Lv${coreSortieResonanceMinLevel} 이상 인연 조원을 함께 편성하십시오.`, this.textStyle(10, '#ffdf7b', true)) + ).setDepth(depth + 1); } const ruleText = this.trackSortie( - this.add.text(x + 18, y + 62, '같은 적을 먼저 친 공명 장수가 지원 거리 안에서 표시 확률로 추격합니다.', this.textStyle(9, '#d4dce6', true)) + this.add.text(x + 18, y + 62, `Lv${coreSortieResonanceMinLevel}+ 한 조 · 핵심 추격 8/18/28% · 재클릭 해제`, this.textStyle(9, '#d4dce6', true)) ); ruleText.setDepth(depth + 1); const missingRoleLabels: Record<(typeof coreSortieSynergyRoles)[number], string> = { @@ -14038,7 +14278,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 }; + this.sortiePursuitPanelView = { background: bg, summaryText, ruleText, ...pursuitPageView }; } private renderFirstSortieLoadoutStep( @@ -14975,6 +15215,77 @@ export class CampScene extends Phaser.Scene { }); } + private currentSortieResonanceBondId(scenario = this.nextSortieScenario()) { + const selection = this.campaign?.sortieResonanceSelection; + return scenario && selection?.battleId === scenario.id ? selection.bondId : undefined; + } + + private sortieCoreResonanceCandidates( + snapshot = this.sortieSynergySnapshot(), + scenario = this.nextSortieScenario() + ): SortieCoreResonanceCandidate[] { + const selectedBondId = this.currentSortieResonanceBondId(scenario); + const seenPairs = new Set(); + return snapshot.activeBonds + .filter((bond) => bond.level >= coreSortieResonanceMinLevel) + .map((bond) => ({ + ...bond, + coreChainRate: coreSortieResonanceChainRateForLevel(bond.level), + selected: bond.id === selectedBondId + })) + .sort((left, right) => + Number(right.selected) - Number(left.selected) || + right.coreChainRate - left.coreChainRate || + right.level - left.level || + left.title.localeCompare(right.title) || + left.id.localeCompare(right.id) + ) + .filter((bond) => { + const pairKey = this.sortiePursuitPairKey(bond); + if (seenPairs.has(pairKey)) { + return false; + } + seenPairs.add(pairKey); + return true; + }); + } + + private changeSortieCoreResonancePage(delta: number) { + const pageCount = Math.max(1, Math.ceil(this.sortieCoreResonanceCandidates().length / 3)); + const nextPage = Phaser.Math.Clamp(this.sortieCoreResonancePage + Math.sign(delta), 0, pageCount - 1); + if (nextPage === this.sortieCoreResonancePage) { + return; + } + this.sortieCoreResonancePage = nextPage; + soundDirector.playSelect(); + this.showSortiePrep(); + } + + private toggleSortieCoreResonance(bondId: string) { + const scenario = this.nextSortieScenario(); + if (!scenario) { + this.showCampNotice('현재 지정할 수 있는 핵심 공명조가 없습니다.'); + return; + } + const candidate = this.sortieCoreResonanceCandidates(undefined, scenario).find((bond) => bond.id === bondId); + if (!candidate) { + this.showCampNotice(`Lv${coreSortieResonanceMinLevel} 이상 인연 조원을 함께 편성해야 핵심 공명조로 지정할 수 있습니다.`); + return; + } + + this.persistSortieSelection(); + const clearing = this.currentSortieResonanceBondId(scenario) === candidate.id; + this.campaign = setCampaignSortieResonanceSelection(scenario.id, clearing ? undefined : candidate.id); + const pairLabel = `${candidate.unitNames[0]}↔${candidate.unitNames[1]}`; + this.sortiePlanFeedback = clearing + ? `핵심 공명조 해제 · ${pairLabel}` + : `핵심 공명조 지정 · ${pairLabel} · 추격 ${candidate.coreChainRate}%`; + this.sortieCoreResonancePage = 0; + soundDirector.playSelect(); + this.showCampNotice(this.sortiePlanFeedback); + this.showSortiePrep(); + } + private sortiePursuitPairs( selectedUnitIds: readonly string[] = this.selectedSortieUnitIds, scenario = this.nextSortieScenario(), @@ -14997,7 +15308,8 @@ export class CampScene extends Phaser.Scene { bonds: this.sortieSynergyBonds(scenario), selectedUnitIds: selectedUnitIds.filter((unitId) => availableIds.has(unitId)), selectableUnitIds: (selectableUnitIds ?? [...availableIds]).filter((unitId) => availableIds.has(unitId)), - battleId: scenario?.id + battleId: scenario?.id, + coreBondId: this.currentSortieResonanceBondId(scenario) }); } @@ -15007,14 +15319,25 @@ export class CampScene extends Phaser.Scene { 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; - }); + const coreBondId = this.currentSortieResonanceBondId(); + return snapshot.activeBonds + .map((bond) => { + const core = bond.id === coreBondId && bond.level >= coreSortieResonanceMinLevel; + return { + ...bond, + baseChainRate: bond.chainRate, + chainRate: core ? coreSortieResonanceChainRateForLevel(bond.level) : bond.chainRate, + core + }; + }) + .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) { @@ -16487,6 +16810,7 @@ export class CampScene extends Phaser.Scene { } const rule = this.nextSortieRule(scenario); + const selectedCore = this.sortieCoreResonanceCandidates(undefined, scenario).find((candidate) => candidate.selected); const allies = this.sortieAllies(); const availableById = new Map(allies.map((unit) => [unit.id, unit])); const orderedIds: string[] = []; @@ -16521,6 +16845,9 @@ export class CampScene extends Phaser.Scene { this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(nextAssignments); this.sortieFocusedUnitId = nextSelectedIds[0] ?? this.sortieFocusedUnitId; this.sortiePlanFeedback = this.recommendedSortieFeedback(nextSelectedIds, nextAssignments, hasBattle); + if (selectedCore && !selectedCore.unitIds.every((unitId) => nextSelectedIds.includes(unitId))) { + this.sortiePlanFeedback += ' · 핵심 공명조 자동 해제'; + } this.persistSortieSelection(); soundDirector.playSelect(); this.showSortiePrep(); @@ -17022,13 +17349,17 @@ export class CampScene extends Phaser.Scene { const sortieFormationAssignments = { ...this.sortieFormationAssignments }; const sortieItemAssignments = this.cloneSortieItemAssignments(this.sortieItemAssignments); const sortieOrderId = this.currentSortieOrderId(); + const sortieResonanceBondId = this.campaign?.sortieResonanceSelection?.battleId === flow.nextBattleId + ? this.campaign.sortieResonanceSelection.bondId + : undefined; if (this.skipIntroStoryForSortie) { void startLazyScene(this, 'BattleScene', { battleId: flow.nextBattleId, selectedSortieUnitIds, sortieFormationAssignments, sortieItemAssignments, - sortieOrderId + sortieOrderId, + sortieResonanceBondId }); return; } @@ -17036,7 +17367,14 @@ export class CampScene extends Phaser.Scene { void startLazyScene(this, 'StoryScene', { pages: flow.pages, nextScene: 'BattleScene', - nextSceneData: { battleId: flow.nextBattleId, selectedSortieUnitIds, sortieFormationAssignments, sortieItemAssignments, sortieOrderId } + nextSceneData: { + battleId: flow.nextBattleId, + selectedSortieUnitIds, + sortieFormationAssignments, + sortieItemAssignments, + sortieOrderId, + sortieResonanceBondId + } }); } @@ -17052,9 +17390,13 @@ export class CampScene extends Phaser.Scene { this.sortiePinnedSwapCandidateUnitId = undefined; this.closeSortiePresetBrowserState(); this.sortieFormationPanelMode = 'roster'; + this.sortieCoreResonanceSelectorOpen = false; + this.sortieCoreResonancePage = 0; } this.sortieComparisonPanelView = undefined; this.sortiePursuitPanelView = undefined; + this.sortieCoreResonanceToggleButton = undefined; + this.sortieCoreResonanceToggleLabel = undefined; this.sortiePresetBrowserView = undefined; this.sortiePresetToggleButton = undefined; this.sortieOrderBrowserView = undefined; @@ -19899,6 +20241,7 @@ export class CampScene extends Phaser.Scene { return; } const synergyPreview = this.sortieUnitSynergyPreview(unit); + const selectedCore = this.sortieCoreResonanceCandidates().find((candidate) => candidate.selected); if (this.isRequiredSortieUnit(unitId)) { this.showCampNotice(`${unit.name}는 반드시 ${hasBattle ? '출전' : '동행'}해야 합니다.`); return; @@ -19927,6 +20270,9 @@ export class CampScene extends Phaser.Scene { const selectionLabel = hasBattle ? '출전' : '동행'; const synergyResultLine = synergyPreview.headline.replace(/^(합류|해제) 시 /, ''); this.sortiePlanFeedback = `${unit.name} ${selected.has(unitId) ? `${selectionLabel} 추가` : `${selectionLabel} 해제`} · ${synergyResultLine}`; + if (selectedCore && !selectedCore.unitIds.every((selectedUnitId) => selected.has(selectedUnitId))) { + this.sortiePlanFeedback += ' · 핵심 공명조 자동 해제'; + } this.persistSortieSelection(); soundDirector.playSelect(); this.showSortiePrep(); @@ -20199,6 +20545,14 @@ export class CampScene extends Phaser.Scene { return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; } + private sortieInteractiveObjectBoundsDebug(object?: Phaser.GameObjects.Rectangle) { + if (!object?.active || !object.visible || !object.input?.enabled) { + return null; + } + const bounds = object.getBounds(); + return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; + } + private sortieTextBoundsDebug(object?: Phaser.GameObjects.Text) { if (!object?.active) { return null; @@ -20224,6 +20578,8 @@ export class CampScene extends Phaser.Scene { const sortieComparisonPreview = this.sortieFormationComparisonPreview(); const sortieComparisonAction = this.sortieComparisonAction(sortieComparisonPreview); const sortiePursuit = this.sortiePursuitPairs(); + const sortieCoreResonanceCandidates = this.sortieCoreResonanceCandidates(); + const sortieCoreResonanceSelection = this.campaign?.sortieResonanceSelection; const recommendedPresetId = this.recommendedSortiePresetId(); const sortieFormationPresets = this.sortieFormationPresets(); const sortieOrderId = this.currentSortieOrderId(); @@ -20692,6 +21048,22 @@ export class CampScene extends Phaser.Scene { sortiePlan: this.sortiePlanSummary(), sortieSynergyPreview: this.sortieSynergySnapshot(), sortiePursuitPreview: { + selectorOpen: this.sortieCoreResonanceSelectorOpen, + toggleBounds: this.sortieObjectBoundsDebug(this.sortieCoreResonanceToggleButton), + toggleClickBounds: this.sortieInteractiveObjectBoundsDebug(this.sortieCoreResonanceToggleButton), + toggleLabel: this.sortieCoreResonanceToggleLabel?.text ?? null, + selectionBattleId: sortieCoreResonanceSelection?.battleId ?? null, + selectedCoreBondId: this.currentSortieResonanceBondId() ?? null, + coreCandidates: sortieCoreResonanceCandidates.map((candidate) => ({ + id: candidate.id, + unitIds: [...candidate.unitIds], + unitNames: [...candidate.unitNames], + title: candidate.title, + level: candidate.level, + baseChainRate: candidate.chainRate, + coreChainRate: candidate.coreChainRate, + selected: candidate.selected + })), activePairs: sortiePursuit.activePairs.map((pair) => ({ id: pair.id, unitIds: [...pair.unitIds], @@ -20699,6 +21071,8 @@ export class CampScene extends Phaser.Scene { title: pair.title, level: pair.level, chainRate: pair.chainRate, + baseChainRate: pair.baseChainRate, + core: pair.core, damageBonus: pair.damageBonus })), selectableOpportunities: sortiePursuit.selectableOpportunities.map((opportunity) => ({ @@ -20708,6 +21082,8 @@ export class CampScene extends Phaser.Scene { title: opportunity.title, level: opportunity.level, chainRate: opportunity.chainRate, + baseChainRate: opportunity.baseChainRate, + core: opportunity.core, damageBonus: opportunity.damageBonus, selectedPartnerId: opportunity.selectedPartnerId, selectedPartnerName: opportunity.selectedPartnerName, @@ -20718,10 +21094,25 @@ export class CampScene extends Phaser.Scene { summaryText: this.sortiePursuitPanelView?.summaryText.text ?? null, ruleText: this.sortiePursuitPanelView?.ruleText.text ?? null, ruleBounds: this.sortieTextBoundsDebug(this.sortiePursuitPanelView?.ruleText), + page: this.sortiePursuitPanelView?.page ?? 0, + pageCount: this.sortiePursuitPanelView?.pageCount ?? 1, + prevEnabled: this.sortiePursuitPanelView?.prevEnabled ?? false, + nextEnabled: this.sortiePursuitPanelView?.nextEnabled ?? false, + prevBounds: this.sortieObjectBoundsDebug(this.sortiePursuitPanelView?.prevButton), + nextBounds: this.sortieObjectBoundsDebug(this.sortiePursuitPanelView?.nextButton), + prevClickBounds: this.sortieInteractiveObjectBoundsDebug(this.sortiePursuitPanelView?.prevButton), + nextClickBounds: this.sortieInteractiveObjectBoundsDebug(this.sortiePursuitPanelView?.nextButton), + pageText: this.sortiePursuitPanelView?.pageText.text ?? null, + pageTextBounds: this.sortieTextBoundsDebug(this.sortiePursuitPanelView?.pageText), rows: this.sortiePursuitPanelView?.rows.map((row) => ({ + bondId: row.bondId, + level: row.level, + chainRate: row.chainRate, + selected: row.selected, state: row.state, text: row.text, - bounds: this.sortieObjectBoundsDebug(row.background), + bounds: this.sortieInteractiveObjectBoundsDebug(row.background), + chipBounds: this.sortieInteractiveObjectBoundsDebug(row.background), textBounds: this.sortieTextBoundsDebug(row.label) })) ?? [] }, @@ -20747,6 +21138,7 @@ export class CampScene extends Phaser.Scene { selectedSortieUnitIds: this.campaign.selectedSortieUnitIds, sortieFormationAssignments: this.campaign.sortieFormationAssignments, sortieItemAssignments: this.campaign.sortieItemAssignments, + sortieResonanceSelection: this.campaign.sortieResonanceSelection ?? null, activeSaveSlot: this.campaign.activeSaveSlot, reserveTrainingAssignments: this.campaign.reserveTrainingAssignments, reserveTrainingFocus: this.campaign.reserveTrainingFocus, diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index f2c987e..b533b59 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -3,6 +3,7 @@ import { isCampaignStep, type CampaignStep } from './campaignState'; import type { UnitDirection } from '../data/unitAssets'; import { equipmentExpToNext, equipmentSlots, type EquipmentSlot } from '../data/battleItems'; import type { SortieOrderId } from '../data/sortieOrders'; +import { coreSortieResonanceMinLevel } from '../data/sortieSynergy'; export type BattleSaveFaction = 'ally' | 'enemy'; export type BattleSaveRosterTab = 'ally' | 'enemy'; @@ -69,11 +70,20 @@ export type BattleSaveTacticalCommandState = { effectLost?: boolean; }; +export type BattleSaveCoreResonanceStats = { + attempts: number; + successes: number; + failures: number; + additionalDamage: number; +}; + export type BattleSaveState = { version: 1; battleId: string; campaignStep?: CampaignStep; sortieOrderId?: SortieOrderId; + sortieResonanceBondId?: string; + coreResonanceStats?: BattleSaveCoreResonanceStats; savedAt: string; turnNumber: number; activeFaction: BattleSaveFaction; @@ -148,12 +158,25 @@ export function parseBattleSaveState(raw: string | null | undefined, options: Ba try { const state = JSON.parse(raw) as unknown; - return isValidBattleSaveState(state, options) ? state : undefined; + return normalizeBattleSaveState(state, options); } catch { return undefined; } } +export function normalizeBattleSaveState( + state: unknown, + options: BattleSaveValidationOptions = {} +): BattleSaveState | undefined { + const cloned = cloneJsonValue(state); + if (!isRecord(cloned)) { + return undefined; + } + + normalizeCoreResonanceSaveFields(cloned, options); + return isValidBattleSaveState(cloned, options) ? cloned : undefined; +} + export function isValidBattleSaveState(state: unknown, options: BattleSaveValidationOptions = {}): state is BattleSaveState { if (!isRecord(state) || state.version !== 1 || !Array.isArray(state.units)) { return false; @@ -183,6 +206,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida return false; } + if (!isOptionalCoreResonanceState(state, options)) { + return false; + } + const attackIntents = state.attackIntents; const units = state.units; @@ -238,6 +265,118 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function cloneJsonValue(value: unknown): unknown { + try { + const serialized = JSON.stringify(value); + return serialized === undefined ? undefined : JSON.parse(serialized) as unknown; + } catch { + return undefined; + } +} + +function normalizeCoreResonanceSaveFields( + state: Record, + options: BattleSaveValidationOptions +) { + const requestedBondId = typeof state.sortieResonanceBondId === 'string' + ? state.sortieResonanceBondId.trim() + : ''; + const bonds = Array.isArray(state.bonds) ? state.bonds : []; + const units = Array.isArray(state.units) ? state.units : []; + const deployedUnitIds = new Set( + units.filter(isRecord).map((unit) => unit.id).filter((unitId): unitId is string => typeof unitId === 'string') + ); + const bond = bonds.find((candidate) => ( + isRecord(candidate) && + candidate.id === requestedBondId && + Number.isInteger(candidate.level) && + Number(candidate.level) >= coreSortieResonanceMinLevel && + Array.isArray(candidate.unitIds) && + candidate.unitIds.length === 2 && + candidate.unitIds.every((unitId) => ( + typeof unitId === 'string' && + deployedUnitIds.has(unitId) && + (!options.validAllyUnitIds || options.validAllyUnitIds.has(unitId)) + )) + )); + + if (!requestedBondId || !bond) { + delete state.sortieResonanceBondId; + delete state.coreResonanceStats; + return; + } + + state.sortieResonanceBondId = requestedBondId; + if (state.coreResonanceStats === undefined) { + return; + } + const rawStats = isRecord(state.coreResonanceStats) ? state.coreResonanceStats : {}; + const rawSuccesses = normalizeCoreResonanceStatValue(rawStats.successes); + const rawFailures = normalizeCoreResonanceStatValue(rawStats.failures); + const attempts = Math.min( + maxBattleUnitStatValue, + Math.max(normalizeCoreResonanceStatValue(rawStats.attempts), rawSuccesses + rawFailures) + ); + const successes = Math.min(rawSuccesses, attempts); + state.coreResonanceStats = { + attempts, + successes, + failures: attempts - successes, + additionalDamage: normalizeCoreResonanceStatValue(rawStats.additionalDamage) + } satisfies BattleSaveCoreResonanceStats; +} + +function normalizeCoreResonanceStatValue(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + return Math.min(maxBattleUnitStatValue, Math.max(0, Math.floor(value))); +} + +function isOptionalCoreResonanceState( + state: Record, + options: BattleSaveValidationOptions +) { + const bondId = state.sortieResonanceBondId; + const stats = state.coreResonanceStats; + if (bondId === undefined) { + return stats === undefined; + } + if (typeof bondId !== 'string' || bondId.length === 0 || !Array.isArray(state.bonds) || !Array.isArray(state.units)) { + return false; + } + const deployedUnitIds = new Set( + state.units.filter(isRecord).map((unit) => unit.id).filter((unitId): unitId is string => typeof unitId === 'string') + ); + const bond = state.bonds.find((candidate) => ( + isRecord(candidate) && + candidate.id === bondId && + Number.isInteger(candidate.level) && + Number(candidate.level) >= coreSortieResonanceMinLevel && + Array.isArray(candidate.unitIds) && + candidate.unitIds.length === 2 && + candidate.unitIds.every((unitId) => ( + typeof unitId === 'string' && + deployedUnitIds.has(unitId) && + (!options.validAllyUnitIds || options.validAllyUnitIds.has(unitId)) + )) + )); + if (!bond) { + return false; + } + if (stats === undefined) { + return true; + } + return ( + isRecord(stats) && + isBattleUnitStatValue(stats.attempts) && + isBattleUnitStatValue(stats.successes) && + isBattleUnitStatValue(stats.failures) && + isBattleUnitStatValue(stats.additionalDamage) && + Number(stats.attempts) === Number(stats.successes) + Number(stats.failures) + ); +} + function isPositiveInteger(value: unknown) { return Number.isInteger(value) && Number(value) >= 1; } diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index c10aea3..22cfc42 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -3,6 +3,7 @@ import { unitClasses } from '../data/battleRules'; import { battleScenarios, type BattleScenarioId } from '../data/battles'; import { campaignRecruitUnits, type BattleBond, type UnitData } from '../data/scenario'; import { normalizeSortieFormationAssignments, type SortieFormationAssignments } from '../data/sortieDeployment'; +import { coreSortieResonanceMinLevel } from '../data/sortieSynergy'; import { isSortieOrderId, sortieOrderCheckIdsByOrder, @@ -395,6 +396,11 @@ export type CampaignSortieOrderSelection = { orderId: CampaignSortiePresetId; }; +export type CampaignSortieResonanceSelection = { + battleId: BattleScenarioId; + bondId: string; +}; + export type CampaignSortieOrderHistory = Partial< Record>> >; @@ -413,6 +419,7 @@ export type CampaignState = { sortieItemAssignments: CampaignSortieItemAssignments; sortieFormationPresets: CampaignSortieFormationPresets; sortieOrderSelection?: CampaignSortieOrderSelection; + sortieResonanceSelection?: CampaignSortieResonanceSelection; sortieOrderHistory: CampaignSortieOrderHistory; claimedSortieOrderRewardIds: string[]; reserveTrainingFocus: CampaignReserveTrainingFocusId; @@ -722,6 +729,32 @@ export function setCampaignSortieOrderSelection(battleId: BattleScenarioId, orde return cloneCampaignState(state); } +export function setCampaignSortieResonanceSelection(battleId: BattleScenarioId, bondId?: string) { + const state = ensureCampaignState(); + if (!hasCampaignBattleScenario(battleId)) { + return cloneCampaignState(state); + } + + if (bondId) { + const normalizedBondId = normalizeKeyString(bondId); + const bond = resolveCampaignSortieResonanceBond(battleId, normalizedBondId, state.bonds); + const selectedUnitIds = new Set(state.selectedSortieUnitIds); + if ( + !bond || + bond.level < coreSortieResonanceMinLevel || + !bond.unitIds.every((unitId) => selectedUnitIds.has(unitId)) + ) { + return cloneCampaignState(state); + } + state.sortieResonanceSelection = { battleId, bondId: bond.id }; + } else if (!state.sortieResonanceSelection || state.sortieResonanceSelection.battleId === battleId) { + delete state.sortieResonanceSelection; + } + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + return cloneCampaignState(state); +} + export function setCampaignReserveTrainingFocus(focusId: CampaignReserveTrainingFocusId) { const state = ensureCampaignState(); const normalizedFocusId = normalizeReserveTrainingFocusId(focusId); @@ -890,6 +923,9 @@ export function setFirstBattleReport(report: FirstBattleReport) { if (state.sortieOrderSelection?.battleId === battleId) { delete state.sortieOrderSelection; } + if (state.sortieResonanceSelection?.battleId === battleId) { + delete state.sortieResonanceSelection; + } state.latestBattleId = battleId; state.updatedAt = new Date().toISOString(); @@ -1089,6 +1125,14 @@ function normalizeCampaignState(state: Partial): CampaignState { if (!normalized.sortieOrderSelection) { delete normalized.sortieOrderSelection; } + normalized.sortieResonanceSelection = normalizeCampaignSortieResonanceSelection( + normalized.sortieResonanceSelection, + normalized.bonds, + normalized.selectedSortieUnitIds + ); + if (!normalized.sortieResonanceSelection) { + delete normalized.sortieResonanceSelection; + } normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory(normalized.sortieOrderHistory); normalized.claimedSortieOrderRewardIds = normalizeCampaignSortieOrderRewardClaims( normalized.claimedSortieOrderRewardIds, @@ -1266,6 +1310,53 @@ function normalizeCampaignSortieOrderSelection(value: unknown): CampaignSortieOr return { battleId: battleId as BattleScenarioId, orderId: value.orderId }; } +function normalizeCampaignSortieResonanceSelection( + value: unknown, + bonds: readonly CampBondSnapshot[], + selectedSortieUnitIds: readonly string[] +): CampaignSortieResonanceSelection | undefined { + if (!isPlainObject(value)) { + return undefined; + } + const battleId = typeof value.battleId === 'string' ? value.battleId : ''; + const bondId = normalizeKeyString(value.bondId); + if (!hasCampaignBattleScenario(battleId) || !bondId) { + return undefined; + } + const bond = resolveCampaignSortieResonanceBond(battleId, bondId, bonds); + const selectedUnitIds = new Set(selectedSortieUnitIds); + if ( + !bond || + bond.level < coreSortieResonanceMinLevel || + !bond.unitIds.every((unitId) => selectedUnitIds.has(unitId)) + ) { + return undefined; + } + return { battleId: battleId as BattleScenarioId, bondId }; +} + +function resolveCampaignSortieResonanceBond( + battleId: string, + bondId: string, + campaignBonds: readonly CampBondSnapshot[] +): BattleBond | undefined { + if (!hasCampaignBattleScenario(battleId)) { + return undefined; + } + const scenarioBond = battleScenarios[battleId].bonds.find((bond) => bond.id === bondId); + if (!scenarioBond) { + return undefined; + } + const campaignBond = campaignBonds.find((bond) => bond.id === bondId); + return campaignBond + ? { ...scenarioBond, level: campaignBond.level, exp: campaignBond.exp } + : scenarioBond; +} + +function hasCampaignBattleScenario(battleId: string): battleId is BattleScenarioId { + return Object.prototype.hasOwnProperty.call(battleScenarios, battleId); +} + export function normalizeCampaignSortieOrderResultSnapshot( value: unknown, outcome?: FirstBattleReport['outcome'],