From 3caf03fe2f1c4b3a273c4aab43ffc9578eadb9e8 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 10 Jul 2026 13:08:56 +0900 Subject: [PATCH] feat: preview sortie composition synergy --- package.json | 1 + scripts/verify-flow.mjs | 29 +++ scripts/verify-release-candidate.mjs | 77 +++++++- scripts/verify-sortie-synergy.mjs | 110 +++++++++++ scripts/verify-static-data.mjs | 1 + src/game/data/sortieSynergy.ts | 152 +++++++++++++++ src/game/scenes/BattleScene.ts | 31 +-- src/game/scenes/CampScene.ts | 278 ++++++++++++++++++++++----- 8 files changed, 609 insertions(+), 70 deletions(-) create mode 100644 scripts/verify-sortie-synergy.mjs create mode 100644 src/game/data/sortieSynergy.ts diff --git a/package.json b/package.json index 60f5219..78821a0 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "verify:campaign-data": "node scripts/verify-campaign-flow-data.mjs", "verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs", "verify:battle-save": "node scripts/verify-battle-save-normalization.mjs", + "verify:sortie-synergy": "node scripts/verify-sortie-synergy.mjs", "verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs", "verify:campaign-recruits": "node scripts/verify-campaign-recruit-data.mjs", "verify:equipment-catalog": "node scripts/verify-equipment-catalog-data.mjs", diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 7d11ad8..ec884cf 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -868,6 +868,13 @@ try { throw new Error(`Expected sortie prep to default to the core brother roster: ${JSON.stringify(sixthCampSortieState)}`); } assertSortieTacticalRoster(sixthCampSortieState, ['liu-bei', 'guan-yu', 'zhang-fei']); + if ( + sixthCampSortieState.sortieSynergyPreview?.activeBondCount !== 3 || + sixthCampSortieState.sortieSynergyPreview?.coveredRoleCount < 2 || + !sixthCampSortieState.sortieSynergyPreview?.strongestBond?.title + ) { + throw new Error(`Expected the core brother roster to expose three active bonds and a strongest effect: ${JSON.stringify(sixthCampSortieState.sortieSynergyPreview)}`); + } await clickSortieRosterUnit(page, 'guan-yu'); await page.waitForTimeout(220); @@ -880,6 +887,13 @@ try { ) { throw new Error(`Expected sortie selection toggle to bench Guan Yu while keeping Liu Bei/Zhang Fei: ${JSON.stringify(sixthCampAfterSortieToggle)}`); } + if ( + sixthCampAfterSortieToggle.sortieSynergyPreview?.activeBondCount !== 1 || + sixthCampAfterSortieToggle.sortieFocusedSynergyPreview?.mode !== 'add' || + sixthCampAfterSortieToggle.sortieFocusedSynergyPreview?.comparison?.activeBondDelta !== 2 + ) { + throw new Error(`Expected benching Guan Yu to leave one bond and preview restoring two: ${JSON.stringify(sixthCampAfterSortieToggle.sortieFocusedSynergyPreview)}`); + } await page.mouse.click(1068, 646); await waitForStoryReady(page); await page.screenshot({ path: 'dist/verification-seventh-bridge-story.png', fullPage: true }); @@ -9610,6 +9624,19 @@ function assertSortieTacticalRoster(state, requiredUnitIds) { if (state.sortieChecklistSummary.complete && state.sortieChecklistSummary.nextIncompleteDetail !== null) { throw new Error(`Expected complete sortie checklist summary to clear next incomplete detail: ${JSON.stringify(state.sortieChecklistSummary)}`); } + + if ( + !state.sortieSynergyPreview || + !Array.isArray(state.sortieSynergyPreview.selectedUnitIds) || + !Array.isArray(state.sortieSynergyPreview.activeBonds) || + !Array.isArray(state.sortieSynergyPreview.missingRoles) || + typeof state.sortieSynergyPreview.activeBondCount !== 'number' || + typeof state.sortieSynergyPreview.coveredRoleCount !== 'number' || + typeof state.sortieSynergyPreview.terrainScore !== 'number' || + typeof state.sortieSynergyPreview.firstPursuitTrinityConfigured !== 'boolean' + ) { + throw new Error(`Expected sortie composition synergy analysis: ${JSON.stringify(state.sortieSynergyPreview)}`); + } if (!state.sortieChecklistSummary.complete && !state.sortieChecklistSummary.nextIncompleteLabel) { throw new Error(`Expected incomplete sortie checklist summary to expose next incomplete item: ${JSON.stringify(state.sortieChecklistSummary)}`); } @@ -9623,6 +9650,8 @@ function assertSortieTacticalRoster(state, requiredUnitIds) { return ( !unit.classRole || !unit.formationRole || + typeof unit.available !== 'boolean' || + typeof unit.availabilityReason !== 'string' || !unit.statLine?.includes('무 ') || !unit.statLine?.includes('지 ') || !unit.equipmentLine?.includes('Lv') || diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 6528afd..d6f67ce 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -170,6 +170,19 @@ try { await waitForSortiePrep(page); await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-prep.png`, fullPage: true }); await assertCanvasPainted(page, 'first camp sortie prep'); + await advanceFirstSortiePrepStep(page, 'formation'); + await page.screenshot({ path: `${screenshotDir}/rc-first-camp-sortie-formation.png`, fullPage: true }); + await assertCanvasPainted(page, 'first camp sortie formation'); + const firstSortieSynergy = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieSynergyPreview); + assert( + firstSortieSynergy?.activeBondCount === 3 && + firstSortieSynergy?.coveredRoleCount === 3 && + firstSortieSynergy?.strongestBond?.damageBonus === 9 && + firstSortieSynergy?.strongestBond?.chainRate === 18 && + firstSortieSynergy?.firstPursuitTrinityConfigured === true, + `Expected first sortie to preview three bonds, the strongest effect, and trinity: ${JSON.stringify(firstSortieSynergy)}` + ); + await advanceFirstSortiePrepStep(page, 'loadout'); await page.mouse.click(1116, 656); await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit'); await page.screenshot({ path: `${screenshotDir}/rc-second-battle-from-first-camp.png`, fullPage: true }); @@ -235,13 +248,65 @@ try { `Expected mid-campaign deployment preview to avoid duplicate tiles: ${JSON.stringify(midCampState?.sortieDeploymentPreview)}` ); - const midCampSelectedSortieUnitIds = midCampState.selectedSortieUnitIds ?? []; - const midCampDeploymentPreview = midCampState.sortieDeploymentPreview ?? []; const midCampNextBattleId = midCampState.nextSortieBattleId; await page.mouse.click(1160, 38); await waitForSortiePrep(page); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-prep.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp sortie prep'); + + const fullRosterCandidateProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp(); + const candidate = state?.sortieRoster?.find((unit) => !unit.selected && unit.available); + if (!candidate || typeof scene?.toggleSortieUnit !== 'function') { + return { candidateId: null, selectedBefore: state?.selectedSortieUnitIds ?? [] }; + } + scene.toggleSortieUnit(candidate.id); + return { candidateId: candidate.id, selectedBefore: state.selectedSortieUnitIds ?? [] }; + }); + assert(fullRosterCandidateProbe.candidateId, `Expected a full-roster swap candidate: ${JSON.stringify(fullRosterCandidateProbe)}`); + await page.waitForFunction((probe) => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.sortieFocusedUnitId === probe.candidateId && + state?.sortieFocusedSynergyPreview?.mode === 'waiting' && + state?.selectedSortieUnitIds?.length === probe.selectedBefore.length && + !state.selectedSortieUnitIds.includes(probe.candidateId); + }, fullRosterCandidateProbe, { timeout: 30000 }); + + const underCapacityProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp(); + const removable = state?.sortieRoster?.find((unit) => unit.selected && !unit.required); + if (!removable || typeof scene?.toggleSortieUnit !== 'function') { + return { removedUnitId: null, selectedBefore: state?.selectedSortieUnitIds ?? [] }; + } + scene.toggleSortieUnit(removable.id); + return { removedUnitId: removable.id, selectedBefore: state.selectedSortieUnitIds ?? [] }; + }); + assert(underCapacityProbe.removedUnitId, `Expected a removable mid-campaign officer: ${JSON.stringify(underCapacityProbe)}`); + await page.waitForFunction((probe) => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.sortieVisible === true && + state?.selectedSortieUnitIds?.length === probe.selectedBefore.length - 1 && + !state.selectedSortieUnitIds.includes(probe.removedUnitId) && + state?.sortieFocusedSynergyPreview?.mode === 'add'; + }, underCapacityProbe, { timeout: 30000 }); + + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitForTitle(page); + await page.mouse.click(962, 310); + await waitForCamp(page); + const restoredUnderCapacityState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + assert( + restoredUnderCapacityState?.selectedSortieUnitIds?.length === underCapacityProbe.selectedBefore.length - 1 && + !restoredUnderCapacityState.selectedSortieUnitIds.includes(underCapacityProbe.removedUnitId), + `Expected an under-capacity manual formation to survive scene recreation: ${JSON.stringify(restoredUnderCapacityState?.selectedSortieUnitIds)} / ${JSON.stringify(underCapacityProbe)}` + ); + + const midCampSelectedSortieUnitIds = restoredUnderCapacityState.selectedSortieUnitIds ?? []; + const midCampDeploymentPreview = restoredUnderCapacityState.sortieDeploymentPreview ?? []; + await page.mouse.click(1160, 38); + await waitForSortiePrep(page); await page.mouse.click(1116, 656); await advanceUntilBattle(page, midCampNextBattleId); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-next-battle.png`, fullPage: true }); @@ -467,6 +532,14 @@ async function waitForSortiePrep(page) { }, undefined, { timeout: 30000 }); } +async function advanceFirstSortiePrepStep(page, expectedStep) { + await page.mouse.click(1116, 656); + await page.waitForFunction((step) => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return camp?.sortieVisible === true && camp?.sortiePrepStep === step; + }, expectedStep, { timeout: 30000 }); +} + async function waitForCampAfterBattleResult(page) { await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; diff --git a/scripts/verify-sortie-synergy.mjs b/scripts/verify-sortie-synergy.mjs new file mode 100644 index 0000000..503a342 --- /dev/null +++ b/scripts/verify-sortie-synergy.mjs @@ -0,0 +1,110 @@ +import { createServer } from 'vite'; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const { + compareSortieSynergy, + evaluateSortieSynergy, + firstPursuitSynergyBattleId, + sortieBondBonusForLevel + } = await server.ssrLoadModule('/src/game/data/sortieSynergy.ts'); + + const boundaryCases = [ + [19, 0, 0], + [20, 3, 0], + [39, 3, 0], + [40, 6, 0], + [49, 6, 0], + [50, 6, 8], + [69, 9, 8], + [70, 9, 18], + [100, 15, 18] + ]; + boundaryCases.forEach(([level, damageBonus, chainRate]) => { + const actual = sortieBondBonusForLevel(level); + assert( + actual.damageBonus === damageBonus && actual.chainRate === chainRate, + `Unexpected Lv${level} bond bonus: ${JSON.stringify(actual)}` + ); + }); + + const members = [ + { id: 'liu-bei', name: '유비', role: 'support', terrainScore: 101 }, + { id: 'guan-yu', name: '관우', role: 'front', terrainScore: 105 }, + { id: 'zhang-fei', name: '장비', role: 'flank', terrainScore: 98 }, + { id: 'jian-yong', name: '간옹', role: 'support', terrainScore: 104 } + ]; + const bonds = [ + { id: 'oath', title: '도원결의', unitIds: ['liu-bei', 'guan-yu'], level: 72, exp: 4 }, + { id: 'brothers', title: '의형제', unitIds: ['guan-yu', 'zhang-fei'], level: 68, exp: 3 }, + { id: 'vow', title: '맹세', unitIds: ['liu-bei', 'zhang-fei'], level: 64, exp: 2 }, + { id: 'records', title: '군영 기록', unitIds: ['liu-bei', 'jian-yong'], level: 51, exp: 1 }, + { id: 'self', title: '잘못된 관계', unitIds: ['liu-bei', 'liu-bei'], level: 99, exp: 0 }, + { id: 'unknown', title: '알 수 없음', unitIds: ['liu-bei', 'ghost'], level: 99, exp: 0 } + ]; + const frozenInputs = JSON.stringify({ members, bonds }); + + const trio = evaluateSortieSynergy({ + members, + bonds, + selectedUnitIds: ['liu-bei', 'guan-yu', 'zhang-fei', 'guan-yu'], + battleId: firstPursuitSynergyBattleId + }); + assert(trio.selectedUnitIds.length === 3, `Expected duplicate selections to collapse: ${JSON.stringify(trio.selectedUnitIds)}`); + assert(trio.activeBondCount === 3, `Expected three active brother bonds: ${JSON.stringify(trio.activeBonds)}`); + assert(trio.strongestBond?.id === 'oath', `Expected Lv72 oath to be strongest: ${JSON.stringify(trio.strongestBond)}`); + assert(trio.strongestBond?.damageBonus === 9 && trio.strongestBond?.chainRate === 18, 'Expected strongest combat preview to match BattleScene rules.'); + assert(trio.coveredRoleCount === 3 && trio.firstPursuitTrinityConfigured, `Expected full role coverage and trinity: ${JSON.stringify(trio)}`); + + const withoutGuan = evaluateSortieSynergy({ + members, + bonds, + selectedUnitIds: ['liu-bei', 'zhang-fei'], + battleId: firstPursuitSynergyBattleId + }); + assert(withoutGuan.activeBondCount === 1 && withoutGuan.activeBonds[0]?.id === 'vow', `Expected only the Liu-Zhang bond after Guan Yu leaves: ${JSON.stringify(withoutGuan.activeBonds)}`); + assert(withoutGuan.coveredRoleCount === 2 && !withoutGuan.firstPursuitTrinityConfigured, 'Expected removing Guan Yu to break role coverage and trinity.'); + + const removal = compareSortieSynergy(trio, withoutGuan); + assert(removal.activeBondDelta === -2 && removal.lostBonds.length === 2, `Expected two lost bonds: ${JSON.stringify(removal)}`); + assert(removal.roleCoverageDelta === -1 && removal.lostRoles[0] === 'front', `Expected the front role to be lost: ${JSON.stringify(removal.lostRoles)}`); + const addition = compareSortieSynergy(withoutGuan, trio); + assert(addition.activeBondDelta === 2 && addition.gainedBonds.length === 2, `Expected adding Guan Yu to restore two bonds: ${JSON.stringify(addition)}`); + assert(addition.roleCoverageDelta === 1 && addition.gainedRoles[0] === 'front', `Expected adding Guan Yu to restore the front role: ${JSON.stringify(addition.gainedRoles)}`); + + const restoredRoles = evaluateSortieSynergy({ + members: members.map((member) => member.id === 'guan-yu' ? { ...member, role: 'support' } : member), + bonds, + selectedUnitIds: ['liu-bei', 'guan-yu', 'zhang-fei'], + battleId: firstPursuitSynergyBattleId + }); + assert(restoredRoles.activeBondCount === 3, 'Expected role changes not to alter active bonds.'); + assert(restoredRoles.coveredRoleCount === 2 && !restoredRoles.firstPursuitTrinityConfigured, 'Expected duplicate support roles to disable trinity.'); + + const reordered = evaluateSortieSynergy({ + members: [...members].reverse(), + bonds: [...bonds].reverse(), + selectedUnitIds: ['zhang-fei', 'guan-yu', 'liu-bei'], + battleId: firstPursuitSynergyBattleId + }); + assert( + reordered.activeBonds.map((bond) => bond.id).join(',') === trio.activeBonds.map((bond) => bond.id).join(','), + `Expected deterministic bond ordering: ${JSON.stringify(reordered.activeBonds)}` + ); + assert(JSON.stringify({ members, bonds }) === frozenInputs, 'Expected synergy evaluation not to mutate its inputs.'); + + console.log('Verified sortie synergy previews, bond thresholds, role coverage, and first-pursuit trinity.'); +} finally { + await server.close(); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index cec3586..0e56fb2 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -4,6 +4,7 @@ const checks = [ 'scripts/verify-campaign-flow-data.mjs', 'scripts/verify-campaign-save-normalization.mjs', 'scripts/verify-battle-save-normalization.mjs', + 'scripts/verify-sortie-synergy.mjs', 'scripts/verify-battle-scenario-data.mjs', 'scripts/verify-camp-reward-data.mjs', 'scripts/verify-campaign-recruit-data.mjs', diff --git a/src/game/data/sortieSynergy.ts b/src/game/data/sortieSynergy.ts new file mode 100644 index 0000000..78ae7b8 --- /dev/null +++ b/src/game/data/sortieSynergy.ts @@ -0,0 +1,152 @@ +import type { SortieFormationRole } from './sortieDeployment'; + +export const coreSortieSynergyRoles = ['front', 'flank', 'support'] as const; +export const firstPursuitSynergyBattleId = 'second-battle-yellow-turban-pursuit'; +export const firstPursuitBrotherUnitIds = ['liu-bei', 'guan-yu', 'zhang-fei'] as const; +export const firstPursuitRolePreviewSummaries: Partial> = { + front: '일반 공격 피해 -8%(최소 1) · 반격 +20%', + flank: '초반 2턴 이동 +1 · 공격 피해 +10%', + support: '공격 책략 +10% · 회복 +2' +}; + +export type SortieSynergyMember = { + id: string; + name: string; + role: SortieFormationRole; + terrainScore: number; +}; + +export type SortieSynergyBond = { + id: string; + title: string; + unitIds: readonly [string, string]; + level: number; + exp?: number; +}; + +export type SortieSynergyActiveBond = { + id: string; + title: string; + unitIds: readonly [string, string]; + unitNames: readonly [string, string]; + level: number; + damageBonus: number; + chainRate: number; +}; + +export type SortieSynergySnapshot = { + selectedUnitIds: string[]; + activeBonds: SortieSynergyActiveBond[]; + activeBondCount: number; + strongestBond?: SortieSynergyActiveBond; + formationRoleCounts: Record; + coveredRoleCount: number; + missingRoles: (typeof coreSortieSynergyRoles)[number][]; + terrainScore: number; + terrainGrade: string; + firstPursuitTrinityConfigured: boolean; +}; + +export type SortieSynergyComparison = { + current: SortieSynergySnapshot; + projected: SortieSynergySnapshot; + activeBondDelta: number; + roleCoverageDelta: number; + terrainDelta: number; + gainedBonds: SortieSynergyActiveBond[]; + lostBonds: SortieSynergyActiveBond[]; + gainedRoles: (typeof coreSortieSynergyRoles)[number][]; + lostRoles: (typeof coreSortieSynergyRoles)[number][]; +}; + +export function sortieBondBonusForLevel(level: number) { + const normalizedLevel = Math.max(0, Math.floor(Number.isFinite(level) ? level : 0)); + return { + damageBonus: Math.floor(normalizedLevel / 20) * 3, + chainRate: normalizedLevel >= 70 ? 18 : normalizedLevel >= 50 ? 8 : 0 + }; +} + +export function sortieTerrainGrade(score: number) { + if (score >= 110) { + return '최적'; + } + if (score >= 102) { + return '유리'; + } + if (score >= 95) { + return '보통'; + } + return score > 0 ? '주의' : '미정'; +} + +export function evaluateSortieSynergy(options: { + members: readonly SortieSynergyMember[]; + bonds: readonly SortieSynergyBond[]; + selectedUnitIds: readonly string[]; + battleId?: string; +}): SortieSynergySnapshot { + const memberById = new Map(options.members.map((member) => [member.id, member])); + const selectedUnitIds = [...new Set(options.selectedUnitIds)].filter((unitId) => memberById.has(unitId)); + const selectedIds = new Set(selectedUnitIds); + const selectedMembers = selectedUnitIds.map((unitId) => memberById.get(unitId)).filter((member): member is SortieSynergyMember => Boolean(member)); + const formationRoleCounts = selectedMembers.reduce( + (counts, member) => { + counts[member.role] += 1; + return counts; + }, + { front: 0, flank: 0, support: 0, reserve: 0 } as Record + ); + const missingRoles = coreSortieSynergyRoles.filter((role) => formationRoleCounts[role] <= 0); + const terrainScore = selectedMembers.length > 0 + ? Math.round(selectedMembers.reduce((total, member) => total + member.terrainScore, 0) / selectedMembers.length) + : 0; + const activeBonds = options.bonds + .filter((bond) => bond.unitIds[0] !== bond.unitIds[1] && bond.unitIds.every((unitId) => selectedIds.has(unitId) && memberById.has(unitId))) + .map((bond) => { + const bonus = sortieBondBonusForLevel(bond.level); + return { + id: bond.id, + title: bond.title, + unitIds: bond.unitIds, + unitNames: [memberById.get(bond.unitIds[0])?.name ?? bond.unitIds[0], memberById.get(bond.unitIds[1])?.name ?? bond.unitIds[1]], + level: Math.max(0, Math.floor(Number.isFinite(bond.level) ? bond.level : 0)), + ...bonus + }; + }) + .sort((left, right) => right.level - left.level || left.title.localeCompare(right.title) || left.id.localeCompare(right.id)); + const firstPursuitTrinityConfigured = options.battleId === firstPursuitSynergyBattleId && + firstPursuitBrotherUnitIds.every((unitId) => selectedIds.has(unitId)) && + coreSortieSynergyRoles.every((role) => firstPursuitBrotherUnitIds.some((unitId) => memberById.get(unitId)?.role === role)); + + return { + selectedUnitIds, + activeBonds, + activeBondCount: activeBonds.length, + strongestBond: activeBonds[0], + formationRoleCounts, + coveredRoleCount: coreSortieSynergyRoles.length - missingRoles.length, + missingRoles, + terrainScore, + terrainGrade: sortieTerrainGrade(terrainScore), + firstPursuitTrinityConfigured + }; +} + +export function compareSortieSynergy(current: SortieSynergySnapshot, projected: SortieSynergySnapshot): SortieSynergyComparison { + const currentBondIds = new Set(current.activeBonds.map((bond) => bond.id)); + const projectedBondIds = new Set(projected.activeBonds.map((bond) => bond.id)); + const currentRoles = new Set(coreSortieSynergyRoles.filter((role) => current.formationRoleCounts[role] > 0)); + const projectedRoles = new Set(coreSortieSynergyRoles.filter((role) => projected.formationRoleCounts[role] > 0)); + return { + current, + projected, + activeBondDelta: projected.activeBondCount - current.activeBondCount, + roleCoverageDelta: projected.coveredRoleCount - current.coveredRoleCount, + terrainDelta: projected.terrainScore - current.terrainScore, + gainedBonds: projected.activeBonds.filter((bond) => !currentBondIds.has(bond.id)), + lostBonds: current.activeBonds.filter((bond) => !projectedBondIds.has(bond.id)), + gainedRoles: coreSortieSynergyRoles.filter((role) => projectedRoles.has(role) && !currentRoles.has(role)), + lostRoles: coreSortieSynergyRoles.filter((role) => currentRoles.has(role) && !projectedRoles.has(role)) + }; +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 534d67e..9a5f679 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -42,6 +42,13 @@ import { type SortieDeploymentSlot, type SortieFormationRole } from '../data/sortieDeployment'; +import { + coreSortieSynergyRoles, + firstPursuitBrotherUnitIds, + firstPursuitRolePreviewSummaries, + firstPursuitSynergyBattleId, + sortieBondBonusForLevel +} from '../data/sortieSynergy'; import { campaignSaveSlotCount, getCampaignState, @@ -1139,9 +1146,8 @@ type SaveSlotMode = 'save' | 'load'; type TurnPromptMode = 'turn-end' | 'post-move' | 'load-confirm' | 'save-overwrite-confirm'; const battleSpeedStorageKey = 'heros-web:battle-speed'; -const firstPursuitRoleEffectBattleId = 'second-battle-yellow-turban-pursuit'; -const firstPursuitBrotherUnitIds = ['liu-bei', 'guan-yu', 'zhang-fei'] as const; -const firstPursuitRoleOrder: SortieFormationRole[] = ['front', 'flank', 'support']; +const firstPursuitRoleEffectBattleId = firstPursuitSynergyBattleId; +const firstPursuitRoleOrder: SortieFormationRole[] = [...coreSortieSynergyRoles]; const tacticalInitiativeThreshold = 3; const tacticalInitiativeFrontReduction = 50; const tacticalInitiativeFlankDamageBonus = 30; @@ -1155,19 +1161,19 @@ const firstPursuitRoleEffects: Partial> = { front: { label: '전열 방호', - summary: '일반 공격 피해 -8%(최소 1) · 반격 +20%', + summary: firstPursuitRolePreviewSummaries.front ?? '', icon: 'leadership', tone: 0x59d18c }, flank: { label: '돌파 선봉', - summary: '초반 2턴 이동 +1 · 공격 피해 +10%', + summary: firstPursuitRolePreviewSummaries.flank ?? '', icon: 'move', tone: 0xd8b15f }, support: { label: '후원 지휘', - summary: '공격 책략 +10% · 회복 +2', + summary: firstPursuitRolePreviewSummaries.support ?? '', icon: 'intelligence', tone: 0x83d6ff } @@ -16992,8 +16998,7 @@ export class BattleScene extends Phaser.Scene { const [first, second] = strongest.bond.unitIds.map((id) => this.unitName(id)); return { - damageBonus: Math.floor(strongest.bond.level / 20) * 3, - chainRate: strongest.bond.level >= 70 ? 18 : strongest.bond.level >= 50 ? 8 : 0, + ...sortieBondBonusForLevel(strongest.bond.level), label: `${strongest.bond.title}: ${first}·${second}`, partnerIds: [strongest.partner.id], extendedRange: strongest.extendedRange @@ -17018,8 +17023,7 @@ export class BattleScene extends Phaser.Scene { const [first, second] = strongest.unitIds.map((id) => this.unitName(id)); return { - damageBonus: Math.floor(strongest.level / 20) * 3, - chainRate: strongest.level >= 70 ? 18 : strongest.level >= 50 ? 8 : 0, + ...sortieBondBonusForLevel(strongest.level), label: `${strongest.title}: ${first}·${second}` }; } @@ -18830,10 +18834,7 @@ export class BattleScene extends Phaser.Scene { } private bondBonus(bond: BondState) { - return { - damageBonus: Math.floor(bond.level / 20) * 3, - chainRate: bond.level >= 70 ? 18 : bond.level >= 50 ? 8 : 0 - }; + return sortieBondBonusForLevel(bond.level); } private unitName(unitId: string) { @@ -19779,7 +19780,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') && Math.floor(candidate.level / 20) * 3 > 0; + return units.length === 2 && units.every((unit) => unit?.faction === 'ally') && sortieBondBonusForLevel(candidate.level).damageBonus > 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; diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 4d6c88b..f151df5 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -23,6 +23,14 @@ import { type SortieFormationAssignments, type SortieFormationRole } from '../data/sortieDeployment'; +import { + compareSortieSynergy, + coreSortieSynergyRoles, + evaluateSortieSynergy, + firstPursuitRolePreviewSummaries, + type SortieSynergyComparison, + type SortieSynergySnapshot +} from '../data/sortieSynergy'; import { caoBreakRecruitBonds, caoBreakRecruitUnits, @@ -92,6 +100,7 @@ import { yilingVanguardBonds, zhugeRecruitBonds, zhugeRecruitUnits, + type BattleBond, type PortraitKey, type UnitData } from '../data/scenario'; @@ -312,6 +321,16 @@ type SortiePlanSummary = { warnings: string[]; }; +type SortieUnitSynergyPreview = { + mode: 'current' | 'add' | 'remove' | 'waiting' | 'blocked'; + headline: string; + detail: string; + compactLabel: string; + current: SortieSynergySnapshot; + projected?: SortieSynergySnapshot; + comparison?: SortieSynergyComparison; +}; + type CampaignTimelineChapter = { id: string; title: string; @@ -12631,7 +12650,13 @@ export class CampScene extends Phaser.Scene { this.renderSortieBattleUiIcon(this.formationRoleIconKey(definition.role), slotX + 18, slotY + 22, 26, depth + 2, filled ? 1 : 0.62); this.trackSortie(this.add.text(slotX + 38, slotY + 10, definition.label, this.textStyle(13, filled ? '#a8ffd0' : '#ffdf7b', true))).setDepth(depth + 2); this.trackSortie(this.add.text(slotX + 12, slotY + 40, unitNames.join(', ') || '담당 없음', this.textStyle(13, filled ? '#f2e3bf' : '#ff9d7d', true))).setDepth(depth + 2); - this.trackSortie(this.add.text(slotX + 12, slotY + 62, this.compactText(definition.description, 12), this.textStyle(9, '#9fb0bf'))).setDepth(depth + 2); + this.trackSortie( + this.add.text(slotX + 12, slotY + 59, firstPursuitRolePreviewSummaries[definition.role] ?? definition.description, { + ...this.textStyle(8, '#9fb0bf'), + wordWrap: { width: slotWidth - 24, useAdvancedWrap: true }, + lineSpacing: 0 + }) + ).setDepth(depth + 2); }); } @@ -12773,29 +12798,44 @@ export class CampScene extends Phaser.Scene { } private renderFirstSortieSynergyPanel(x: number, y: number, width: number, height: number, depth: number) { - const plan = this.sortiePlanSummary(); - const selectedIds = new Set(this.selectedSortieUnitIds); - const activeBonds = this.currentBonds() - .filter((bond) => bond.unitIds.every((unitId) => selectedIds.has(unitId))) - .sort((a, b) => b.level - a.level || b.exp - a.exp) - .slice(0, 3); + const synergy = this.sortieSynergySnapshot(); const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.94)); bg.setOrigin(0); bg.setDepth(depth); - bg.setStrokeStyle(1, plan.warningCount > 0 ? palette.gold : palette.green, 0.54); - this.trackSortie(this.add.text(x + 18, y + 14, `공명 연계 ${plan.activeBondCount}`, this.textStyle(18, '#f2e3bf', true))).setDepth(depth + 1); - this.trackSortie(this.add.text(x + width - 18, y + 17, `지형 ${plan.terrainGrade}`, this.textStyle(12, '#d8b15f', true))).setOrigin(1, 0).setDepth(depth + 1); - const activeBondLine = activeBonds.length > 0 - ? activeBonds.map((bond) => `${bond.title} Lv${bond.level}`).join(' · ') - : '활성 공명 없음'; - this.trackSortie(this.add.text(x + 18, y + 44, this.compactText(activeBondLine, 34), this.textStyle(13, activeBonds.length > 0 ? '#a8ffd0' : '#ffdf7b', true))).setDepth(depth + 1); - const feedback = this.sortiePlanFeedback || plan.warningLine || '전열·돌파·후원이 갖춰졌습니다.'; + bg.setStrokeStyle(1, synergy.firstPursuitTrinityConfigured ? palette.green : palette.gold, 0.62); this.trackSortie( - this.add.text(x + 18, y + 70, feedback, { - ...this.textStyle(12, plan.warningCount > 0 && !this.sortiePlanFeedback ? '#ffdf7b' : '#d4dce6'), - wordWrap: { width: width - 36, useAdvancedWrap: true }, - lineSpacing: 2 - }) + this.add.text( + x + 18, + y + 12, + `조합 시너지 · 공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3`, + this.textStyle(17, '#f2e3bf', true) + ) + ).setDepth(depth + 1); + this.trackSortie(this.add.text(x + width - 18, y + 15, `지형 ${synergy.terrainGrade}`, this.textStyle(11, '#d8b15f', true))).setOrigin(1, 0).setDepth(depth + 1); + const activeBondLine = synergy.activeBonds.length > 0 + ? `${synergy.activeBonds.slice(0, 2).map((bond) => `${bond.title} Lv${bond.level}`).join(' · ')}${synergy.activeBonds.length > 2 ? ` · 외 ${synergy.activeBonds.length - 2}` : ''}` + : '활성 공명 없음'; + this.trackSortie(this.add.text(x + 18, y + 40, this.compactText(activeBondLine, 38), this.textStyle(12, synergy.activeBonds.length > 0 ? '#a8ffd0' : '#ffdf7b', true))).setDepth(depth + 1); + const strongest = synergy.strongestBond; + const strongestLine = strongest + ? `최강 공명 · 조건 충족 시 피해 +${strongest.damageBonus}% / 연계 ${strongest.chainRate}%` + : '공명 파트너를 함께 편성하면 전투 연계가 열립니다.'; + this.trackSortie( + this.add.text(x + 18, y + 64, this.compactText(strongestLine, 44), this.textStyle(11, strongest ? '#d4dce6' : '#9fb0bf')) + ).setDepth(depth + 1); + const missingRoleLabels: Record<(typeof coreSortieSynergyRoles)[number], string> = { + front: '전열', + flank: '돌파', + support: '후원' + }; + const formationLine = synergy.firstPursuitTrinityConfigured + ? '삼재진 활성 · 공명 지원 거리 2칸' + : synergy.missingRoles.length > 0 + ? `삼재진 대기 · ${synergy.missingRoles.map((role) => missingRoleLabels[role]).join('·')} 역할 필요` + : '삼재진 대기 · 세 형제의 역할을 나누십시오.'; + const bottomLine = this.sortiePlanFeedback ? `${formationLine} · ${this.sortiePlanFeedback}` : formationLine; + this.trackSortie( + this.add.text(x + 18, y + 88, this.compactText(bottomLine, 47), this.textStyle(11, synergy.firstPursuitTrinityConfigured ? '#a8ffd0' : '#ffdf7b', true)) ).setDepth(depth + 1); } @@ -12910,9 +12950,9 @@ export class CampScene extends Phaser.Scene { const selectedIds = new Set(selectedUnits.map((unit) => unit.id)); const reserveUnits = this.sortieAllies().filter((unit) => !selectedIds.has(unit.id)); const selectedNames = selectedUnits.map((unit) => unit.name).join(', '); - const recommendedClassLine = this.sortieRecommendedClassLine(); const maxUnits = this.sortieMaxUnits(); const hasBattle = Boolean(this.nextSortieScenario()); + const synergy = this.sortieSynergySnapshot(); const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x0d141c, 0.78)); bg.setOrigin(0); bg.setDepth(depth); @@ -12932,7 +12972,10 @@ export class CampScene extends Phaser.Scene { this.add.text( x + 44, y + 35, - this.compactText(`${hasBattle ? '추천' : '정비'} ${recommendedClassLine} · 대기 ${reserveUnits.length}명`, 35), + this.compactText( + `조합 공명 ${synergy.activeBondCount} · 역할 ${synergy.coveredRoleCount}/3${hasBattle ? ` · 지형 ${synergy.terrainGrade}` : ''} · 대기 ${reserveUnits.length}명`, + 35 + ), this.textStyle(12, reserveUnits.length > 0 ? '#c8d2dd' : '#9fb0bf', true) ) ).setDepth(depth + 1); @@ -13052,11 +13095,10 @@ export class CampScene extends Phaser.Scene { visibleAllies.forEach((unit, index) => { const rowY = y + listTopOffset + index * rowGap; const selected = this.isSortieSelected(unit.id); - const required = this.isRequiredSortieUnit(unit.id); const recommendation = this.sortieRecommendation(unit.id); - const summary = this.sortieUnitTacticalSummary(unit); const availability = this.sortieUnitAvailability(unit); const status = this.unitRosterStatus(unit); + const synergyPreview = this.sortieUnitSynergyPreview(unit); const recommendedReserve = Boolean(recommendation && !selected); const disabled = !availability.available; const rowColor = disabled ? 0x12161b : selected ? 0x172a22 : recommendedReserve ? 0x241f17 : 0x151b24; @@ -13096,7 +13138,10 @@ export class CampScene extends Phaser.Scene { const statusText = this.trackSortie(this.add.text(x + width - 28, rowY - 1, status.label, this.textStyle(denseRoster ? 8 : 10, statusColor, true))); statusText.setOrigin(1, 0); statusText.setDepth(depth + 2); - this.trackSortie(this.add.text(x + 58, rowY + (denseRoster ? 10 : 17), this.compactText(`${unit.className} · HP ${unit.hp}/${unit.maxHp}`, denseRoster ? 32 : 23), this.textStyle(denseRoster ? 8 : 10, disabled ? '#68727e' : '#d4dce6', true))).setDepth(depth + 2); + const classLine = denseRoster + ? `${unit.className} · ${synergyPreview.compactLabel}` + : `${unit.className} · HP ${unit.hp}/${unit.maxHp}`; + this.trackSortie(this.add.text(x + 58, rowY + (denseRoster ? 10 : 17), this.compactText(classLine, denseRoster ? 32 : 23), this.textStyle(denseRoster ? 8 : 10, disabled ? '#68727e' : '#d4dce6', true))).setDepth(depth + 2); if (denseRoster) { return; } @@ -13104,8 +13149,17 @@ export class CampScene extends Phaser.Scene { this.drawSortieBar(x + 218, rowY + 22, 58, 6, unit.hp / unit.maxHp, disabled ? 0x53606c : selected ? palette.green : palette.gold, depth + 2); this.renderSortieEquipmentIcons(unit, x + 292, rowY + 24, !disabled && selected, depth + 2, 16, 12, 20); } - const secondLine = disabled ? availability.reason : recommendation && !selected ? this.sortieRecommendationHint(recommendation, unit, 22) : summary.bondLine; - this.trackSortie(this.add.text(x + 58, rowY + 32, this.compactText(secondLine, 36), this.textStyle(9, disabled ? '#68727e' : selected ? '#a8ffd0' : recommendedReserve ? '#ffdf7b' : '#87919c', selected || recommendedReserve))).setDepth(depth + 2); + const secondLine = disabled ? availability.reason : synergyPreview.headline; + const synergyColor = synergyPreview.mode === 'remove' + ? '#ff9d7d' + : synergyPreview.mode === 'add' + ? '#a8ffd0' + : synergyPreview.mode === 'waiting' + ? '#ffdf7b' + : selected + ? '#a8ffd0' + : '#87919c'; + this.trackSortie(this.add.text(x + 58, rowY + 32, this.compactText(secondLine, 36), this.textStyle(9, disabled ? '#68727e' : synergyColor, selected || recommendedReserve))).setDepth(depth + 2); }); if (maxScroll > 0) { @@ -13344,24 +13398,19 @@ export class CampScene extends Phaser.Scene { const status = this.unitRosterStatus(unit); const availability = this.sortieUnitAvailability(unit); - const recommendation = this.sortieRecommendation(unit.id); const hasBattle = Boolean(this.nextSortieScenario()); - const roleLabel = this.sortieFormationRoleLabel(this.sortieFormationRole(unit), hasBattle); const terrainScore = this.sortieTerrainScore(unit, this.nextSortieScenario()); const terrainGrade = this.sortieTerrainGrade(terrainScore); const statusColor = status.tone === 'disabled' ? '#77818c' : status.tone === 'selected' ? '#a8ffd0' : status.tone === 'recommended' ? '#ffdf7b' : '#9fb0bf'; const selected = this.isSortieSelected(unit.id); - const reason = !availability.available - ? availability.reason - : recommendation - ? this.sortieRecommendationHint(recommendation, unit, 26) - : selected - ? hasBattle - ? `${roleLabel} 배치 중입니다. 역할 버튼으로 시작 위치 성향을 바꿀 수 있습니다.` - : `${roleLabel} 성향으로 동행 중입니다. 역할 버튼으로 군영 기록 성향을 바꿀 수 있습니다.` - : hasBattle - ? '명단이나 버튼을 눌러 출전 슬롯에 배치할 수 있습니다.' - : '명단이나 버튼을 눌러 다음 장면 동행 명단에 넣을 수 있습니다.'; + const synergyPreview = this.sortieUnitSynergyPreview(unit); + const synergyColor = synergyPreview.mode === 'remove' + ? '#ff9d7d' + : synergyPreview.mode === 'add' || synergyPreview.mode === 'current' + ? '#a8ffd0' + : synergyPreview.mode === 'waiting' + ? '#ffdf7b' + : '#87919c'; this.trackSortie(this.add.text(x + 18, y + 14, '선택 무장 상세', this.textStyle(17, '#f2e3bf', true))).setDepth(depth + 1); this.trackSortie(this.add.text(x + width - 16, y + 16, status.label, this.textStyle(11, statusColor, true))).setOrigin(1, 0).setDepth(depth + 1); @@ -13379,7 +13428,7 @@ export class CampScene extends Phaser.Scene { this.renderSortieStatChip('move', '이', unit.move, x + 252, statY, chipWidth, depth + 1); this.trackSortie(this.add.text(x + 18, y + 137, `지형 ${terrainGrade} ${terrainScore} · ${this.sortieTerrainLine(unit)}`, this.textStyle(11, '#d8b15f', true))).setDepth(depth + 1); - this.trackSortie(this.add.text(x + 18, y + 157, this.compactText(reason, 38), this.textStyle(11, recommendation ? '#ffdf7b' : selected ? '#a8ffd0' : availability.available ? '#c8d2dd' : '#87919c', true))).setDepth(depth + 1); + this.trackSortie(this.add.text(x + 18, y + 157, this.compactText(synergyPreview.headline, 38), this.textStyle(11, synergyColor, true))).setDepth(depth + 1); equipmentSlots.forEach((slot, index) => { const rowY = y + 174 + index * 20; @@ -13408,12 +13457,14 @@ export class CampScene extends Phaser.Scene { ? '출전 등록' : '동행 등록'; this.renderSortiePanelButton(actionLabel, x + 18, y + height - 28, 108, 22, availability.available, selected, () => this.toggleSortieUnit(unit.id), depth + 1); - const footerText = selected - ? hasBattle - ? `${roleLabel} · 역할은 중앙 버튼으로 변경` - : `${roleLabel} · 의정 역할은 중앙 버튼으로 변경` - : this.reserveTrainingPlanLine(unit); - this.trackSortie(this.add.text(x + 140, y + height - 24, footerText, this.textStyle(10, selected ? '#a8ffd0' : '#9fb0bf', true))).setDepth(depth + 1); + this.trackSortie( + this.add.text( + x + 140, + y + height - 24, + this.compactText(synergyPreview.detail, 30), + this.textStyle(10, synergyColor, true) + ) + ).setDepth(depth + 1); } private renderSortieBattleUiIcon(iconKey: BattleUiIconKey, x: number, y: number, size: number, depth: number, alpha = 1) { @@ -13630,6 +13681,117 @@ export class CampScene extends Phaser.Scene { return '공명 관계 없음'; } + private sortieSynergyBonds(scenario = this.nextSortieScenario()): BattleBond[] { + const campaignProgress = new Map(this.currentBonds().map((bond) => [bond.id, bond])); + const sourceBonds = scenario?.bonds ?? this.currentBonds(); + return sourceBonds.map((bond) => { + const progress = campaignProgress.get(bond.id); + return progress + ? { ...bond, level: progress.level, exp: progress.exp } + : { ...bond }; + }); + } + + private sortieSynergySnapshot( + selectedUnitIds: readonly string[] = this.selectedSortieUnitIds, + scenario = this.nextSortieScenario() + ) { + return evaluateSortieSynergy({ + members: this.sortieRosterUnits().map((unit) => ({ + id: unit.id, + name: unit.name, + role: this.sortieFormationRole(unit, scenario), + terrainScore: this.sortieTerrainScore(unit, scenario) + })), + bonds: this.sortieSynergyBonds(scenario), + selectedUnitIds, + battleId: scenario?.id + }); + } + + private sortieUnitSynergyPreview(unit: UnitData): SortieUnitSynergyPreview { + const scenario = this.nextSortieScenario(); + const current = this.sortieSynergySnapshot(this.selectedSortieUnitIds, scenario); + const availability = this.sortieUnitAvailability(unit, scenario); + const selected = this.isSortieSelected(unit.id); + const role = this.sortieFormationRole(unit, scenario); + const roleLabel = this.sortieFormationRoleLabel(role, Boolean(scenario)); + + if (!availability.available) { + return { + mode: 'blocked', + headline: availability.reason, + detail: `${unit.name}은 현재 편성할 수 없습니다.`, + compactLabel: '편성 불가', + current + }; + } + + if (selected && this.isRequiredSortieUnit(unit.id)) { + const relatedBonds = current.activeBonds.filter((bond) => bond.unitIds.includes(unit.id)); + const strongest = relatedBonds[0]; + return { + mode: 'current', + headline: `필수 편성 · 공명 ${relatedBonds.length}개 연결 · ${roleLabel} 유지`, + detail: strongest + ? `${strongest.title} Lv${strongest.level} · 조건 충족 시 피해 +${strongest.damageBonus}% / 연계 ${strongest.chainRate}%` + : `현재 활성 공명 없음 · 지형 ${current.terrainGrade} ${current.terrainScore}`, + compactLabel: `필수 · 공명 ${relatedBonds.length}`, + current + }; + } + + if (!selected && this.selectedSortieUnitIds.length >= this.sortieMaxUnits(scenario)) { + const selectedIds = new Set(this.selectedSortieUnitIds); + const potentialBonds = this.sortieSynergyBonds(scenario) + .filter((bond) => bond.unitIds.includes(unit.id) && bond.unitIds.some((unitId) => unitId !== unit.id && selectedIds.has(unitId))) + .sort((left, right) => right.level - left.level || left.title.localeCompare(right.title)); + const strongest = potentialBonds[0]; + const strongestPartnerId = strongest?.unitIds.find((unitId) => unitId !== unit.id); + const terrainScore = this.sortieTerrainScore(unit, scenario); + return { + mode: 'waiting', + headline: strongest + ? `교대 후보 · ${potentialBonds.length}개 공명 연결 가능` + : `교대 후보 · 연결 가능한 공명 없음`, + detail: strongest + ? `${this.unitName(strongestPartnerId ?? '')}와 ${strongest.title} Lv${strongest.level} · ${roleLabel} · 지형 ${this.sortieTerrainGrade(terrainScore)}` + : `${roleLabel} 후보 · 지형 ${this.sortieTerrainGrade(terrainScore)} ${terrainScore} · 먼저 슬롯을 비우십시오.`, + compactLabel: `교대 · 공명 ${potentialBonds.length}`, + current + }; + } + + const projectedIds = selected + ? this.selectedSortieUnitIds.filter((unitId) => unitId !== unit.id) + : [...this.selectedSortieUnitIds, unit.id]; + const projected = this.sortieSynergySnapshot(projectedIds, scenario); + const comparison = compareSortieSynergy(current, projected); + const prefix = selected ? '해제 시' : '합류 시'; + const changedBonds = selected ? comparison.lostBonds : comparison.gainedBonds; + const bondMark = selected ? '−' : '+'; + const bondDetail = changedBonds.length > 0 + ? `${bondMark} ${changedBonds.slice(0, 2).map((bond) => `${bond.title} Lv${bond.level}`).join(' · ')}` + : '공명 변화 없음'; + const strongest = projected.strongestBond; + const terrainLine = `지형 ${current.terrainGrade} ${current.terrainScore}→${projected.terrainGrade} ${projected.terrainScore}`; + const strongestLine = strongest + ? `최강 피해 +${strongest.damageBonus}% / 연계 ${strongest.chainRate}%` + : '활성 공명 없음'; + const signedBondDelta = comparison.activeBondDelta > 0 ? `+${comparison.activeBondDelta}` : `${comparison.activeBondDelta}`; + const signedRoleDelta = comparison.roleCoverageDelta > 0 ? `+${comparison.roleCoverageDelta}` : `${comparison.roleCoverageDelta}`; + + return { + mode: selected ? 'remove' : 'add', + headline: `${prefix} 공명 ${current.activeBondCount}→${projected.activeBondCount} · 역할 ${current.coveredRoleCount}/3→${projected.coveredRoleCount}/3`, + detail: `${bondDetail} · ${strongestLine} · ${terrainLine}`, + compactLabel: `공명 ${signedBondDelta} · 역할 ${signedRoleDelta}`, + current, + projected, + comparison + }; + } + private sortieTerrainLine(unit: UnitData) { const scenario = this.nextSortieScenario(); if (!scenario) { @@ -13950,7 +14112,7 @@ export class CampScene extends Phaser.Scene { ? Math.round(terrainScores.reduce((sum, score) => sum + score, 0) / terrainScores.length) : 0; const terrainGrade = this.sortieTerrainGrade(terrainScore); - const activeBondCount = this.currentBonds().filter((bond) => bond.unitIds.every((unitId) => selectedIds.has(unitId))).length; + const activeBondCount = this.sortieSynergyBonds(scenario).filter((bond) => bond.unitIds.every((unitId) => selectedIds.has(unitId))).length; const recommendedSelectedCount = recommended.filter((entry) => selectedIds.has(entry.unitId)).length; const recommendedClassSelectedCount = recommendedClasses.length - missingClassRecommendations.length; const recommendedClassCoverageLine = recommendedClasses.length > 0 @@ -16067,7 +16229,7 @@ export class CampScene extends Phaser.Scene { candidateSet.add(unitId); } }; - if (useDefaultSelection || candidateSet.size < maxUnits) { + if (useDefaultSelection) { const rule = this.nextSortieRule(); rule.recommended.forEach((entry) => addDefaultCandidate(entry.unitId)); (rule.recommendedClasses ?? []).forEach((entry) => { @@ -16114,6 +16276,7 @@ export class CampScene extends Phaser.Scene { this.showCampNotice(`${unit.name}: ${availability.reason}`); return; } + const synergyPreview = this.sortieUnitSynergyPreview(unit); if (this.isRequiredSortieUnit(unitId)) { this.showCampNotice(`${unit.name}는 반드시 ${hasBattle ? '출전' : '동행'}해야 합니다.`); return; @@ -16129,7 +16292,9 @@ export class CampScene extends Phaser.Scene { delete nextItemAssignments[unitId]; this.sortieItemAssignments = nextItemAssignments; } else if (selected.size >= this.sortieMaxUnits()) { - this.showCampNotice(`${hasBattle ? '이번 전투' : '이번 장면'}는 최대 ${this.sortieMaxUnits()}명까지 ${hasBattle ? '출전' : '동행'}할 수 있습니다.`); + this.sortiePlanFeedback = `${unit.name} 교대 후보 · 먼저 ${hasBattle ? '출전' : '동행'} 슬롯을 비우십시오.`; + soundDirector.playSelect(); + this.showSortiePrep(); return; } else { selected.add(unitId); @@ -16137,9 +16302,8 @@ export class CampScene extends Phaser.Scene { this.selectedSortieUnitIds = this.normalizedSortieUnitIds(Array.from(selected)); const selectionLabel = hasBattle ? '출전' : '동행'; - this.sortiePlanFeedback = selected.has(unitId) - ? `${unit.name} ${selectionLabel} 추가 · 직접 편성 중` - : `${unit.name} ${selectionLabel} 해제 · 직접 편성 중`; + const synergyResultLine = synergyPreview.headline.replace(/^(합류|해제) 시 /, ''); + this.sortiePlanFeedback = `${unit.name} ${selected.has(unitId) ? `${selectionLabel} 추가` : `${selectionLabel} 해제`} · ${synergyResultLine}`; this.persistSortieSelection(); soundDirector.playSelect(); this.showSortiePrep(); @@ -16400,6 +16564,7 @@ export class CampScene extends Phaser.Scene { const reserveTrainingAssignments = this.campaign?.reserveTrainingAssignments ?? {}; const reserveTrainingFocus = this.reserveTrainingFocusDefinition(); const reserveTrainingUnits = this.reserveTrainingPreviewUnits(); + const focusedSortieUnit = this.sortieFocusedUnit(); return { scene: this.scene.key, activeTab: this.activeTab, @@ -16434,9 +16599,12 @@ export class CampScene extends Phaser.Scene { sortieRoster: this.sortieAllies().map((unit) => { const summary = this.sortieUnitTacticalSummary(unit); const reserveTrainingFocus = this.reserveTrainingFocusDefinitionForUnit(unit.id); + const availability = this.sortieUnitAvailability(unit); return { id: unit.id, name: unit.name, + available: availability.available, + availabilityReason: availability.reason, selected: this.isSortieSelected(unit.id), recruited: !foundingSortieUnitIds.has(unit.id), required: this.isRequiredSortieUnit(unit.id), @@ -16466,6 +16634,10 @@ export class CampScene extends Phaser.Scene { bonusText: this.itemBonusText(entry.item) })), sortiePlan: this.sortiePlanSummary(), + sortieSynergyPreview: this.sortieSynergySnapshot(), + sortieFocusedSynergyPreview: focusedSortieUnit + ? this.sortieUnitSynergyPreview(focusedSortieUnit) + : null, rosterCollection: this.rosterCollectionSummary(), reserveTrainingAwards: this.latestReserveTrainingAwards(), campaignProgress: this.campaignTimelineProgress(),