From b484daf95680ac9aba44e4da660374d1eaace52a Mon Sep 17 00:00:00 2001 From: dal4segno Date: Sat, 5 Sep 2026 11:27:52 +0900 Subject: [PATCH] fix: preserve equivalent cards in board optimization --- UPDATES.md | 4 ++ .../engine/board-connection-optimizer.test.ts | 40 +++++++++++++++++++ src/lib/engine/board-connection-optimizer.ts | 40 ++++++++++++++++++- src/routes/analysis/+page.svelte | 1 + 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/UPDATES.md b/UPDATES.md index ae374c8..81a27bd 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -4,6 +4,10 @@ updatedAt: 2026-09-05T10:20:00+09:00 ## 2026년 9월 5일 +### 보드 최적화의 불필요한 교체 제거 + +`FIXED` 배치 분석의 보드 최적화에서 연결 모양과 강화 수치가 같은 카드로 교체하도록 안내하던 문제를 수정했습니다. 효과가 같으면 기존에 배치한 카드를 유지합니다. + ### 게이머즈 소속 조건 판정 수정 `FIXED` 게이머즈 멤버가 소속 조건에서 누락되던 문제를 수정했습니다. 수영복 오카유, 일반 5성 미오, 4성 오카유·코로네의 게이머즈 조건 및 대상 지정이 있는 리더 의상·패시브·스킬 효과가 정상 적용됩니다. diff --git a/src/lib/engine/board-connection-optimizer.test.ts b/src/lib/engine/board-connection-optimizer.test.ts index 9137e4e..8a14d6c 100644 --- a/src/lib/engine/board-connection-optimizer.test.ts +++ b/src/lib/engine/board-connection-optimizer.test.ts @@ -31,6 +31,46 @@ const rightCard = { connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }] }; +test('모양과 수치가 같은 카드는 기존 배치를 유지하고 중복 배치하지 않는다', () => { + const cards = ['a', 'b', 'c'].map((id) => ({ ...rightCard, id })); + const currentPlacements = { member: { 'S-center': 'c', 'S-right': 'a' } }; + const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-right', 'G-far'] }, cards, { currentPlacements }); + assert.deepEqual(result.placements, currentPlacements); + assert.equal(new Set(result.assignments.map((assignment) => assignment.cardId)).size, 2); + assert.equal(result.totalWeight, 900_000); +}); + +test('개화에 따른 실제 효과와 좌표 순서에 무관한 모양으로 기존 카드를 유지한다', () => { + const effect = { level: 1, effectPermilUp: 1000, extent: [{ x: -1, y: 0 }, { x: 1, y: 0 }] }; + const cards = [ + { id: 'a', awakening: 0, connect: [effect] }, + { id: 'b', awakening: 5, connect: [ + { ...effect, effectPermilUp: 100 }, + { ...effect, level: 2, extent: [...effect.extent].reverse() } + ] } + ]; + const currentPlacements = { member: { 'S-center': 'b' } }; + const result = optimizeBoardConnectionCards(source, { member: ['G-left', 'G-right'] }, cards, { currentPlacements }); + assert.deepEqual(result.placements, currentPlacements); +}); + +test('기존 카드와 모양 또는 수치가 다르면 실제 개선 배치를 유지한다', () => { + for (const previous of [leftCard, { ...rightCard, id: 'weak', connect: [{ ...rightCard.connect[0], effectPermilUp: 100 }] }]) { + const result = optimizeBoardConnectionCards(source, { member: ['G-left', 'G-right'] }, [previous, rightCard], { + currentPlacements: { member: { 'S-center': previous.id } } + }); + assert.equal(result.placements.member['S-center'], rightCard.id); + } +}); + +test('기존 카드를 유지해도 다른 소켓에 필요한 동등 카드를 중복 사용하지 않는다', () => { + const cards = ['a', 'b'].map((id) => ({ ...rightCard, id })); + const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-right', 'G-far'] }, cards, { + currentPlacements: { member: { 'S-center': 'b' } } + }); + assert.deepEqual(result.placements, { member: { 'S-center': 'b', 'S-right': 'a' } }); +}); + test('활성 연결 노드 전체에서 보유 카드의 최대 가중치 배치를 찾는다', () => { const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard]); assert.deepEqual(result.placements, { diff --git a/src/lib/engine/board-connection-optimizer.ts b/src/lib/engine/board-connection-optimizer.ts index e60d73d..748b8fb 100644 --- a/src/lib/engine/board-connection-optimizer.ts +++ b/src/lib/engine/board-connection-optimizer.ts @@ -47,6 +47,7 @@ export type BoardConnectionOptimizationResult = { }; export type BoardConnectionOptimizationOptions = { + currentPlacements?: Readonly>>>; targetMemberId?: string; requireSoloSongBonus?: boolean; requireGroupSongBonus?: boolean; @@ -202,7 +203,44 @@ export function optimizeBoardConnectionCards( || right.fulfillment - left.fulfillment || right.totalFulfillment - left.totalFulfillment || right.genericWeight - left.genericWeight)[0]; - const matches = best.matches; + const matches = best.matches.map((match) => ({ ...match })); + if (options.currentPlacements) { + // Equivalent cards are interchangeable. Reserve existing cards first, then + // distribute the remaining cards so preserving one socket cannot duplicate a card. + const equivalentCards = new Map(); + cards.forEach((card, cardIndex) => { + const effect = selectedEffect(card)!; + const key = JSON.stringify([ + effect.effectPermilUp, + effect.extent.map(({ x, y }) => `${x},${y}`).sort(), + genericWeights[cardIndex], priorityUnits[cardIndex], + soloBoosts[cardIndex], groupBoosts[cardIndex] + ]); + const group = equivalentCards.get(key) ?? []; + group.push(cardIndex); + equivalentCards.set(key, group); + }); + for (const group of equivalentCards.values()) { + const available = new Set(group); + const groupMatches = matches.filter((match) => available.has(match.rowIndex)); + const pending = []; + for (const match of groupMatches) { + const { member, socket } = slots[match.columnIndex]; + const currentId = options.currentPlacements[member.id]?.[socket.groupId]; + const currentIndex = group.find((index) => cards[index].id === currentId); + if (currentIndex !== undefined && available.delete(currentIndex)) { + match.rowIndex = currentIndex; + } else { + pending.push(match); + } + } + for (const match of pending) { + const index = available.has(match.rowIndex) ? match.rowIndex : available.values().next().value!; + match.rowIndex = index; + available.delete(index); + } + } + } const placements: Record> = {}; const assignments = matches.map((match) => { const card = cards[match.rowIndex]; diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index e524bfd..8e73900 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -592,6 +592,7 @@ connectionOptimizationProgress = { stage: '최적 조합 선택 중', completed: totalPlacementChecks, total: totalPlacementChecks }; await new Promise((resolve) => requestAnimationFrame(() => resolve())); const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, { + currentPlacements: boardCards, placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score, placementPriorityWeight: (card, memberId, socketId) => isBlueConnectionSocket(memberId, socketId) ? placementMetrics(card, memberId, socketId).activationRateUp