From 9d2d8963b521bf29165cc8a01b363ca3ffc062c8 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Sun, 6 Sep 2026 18:28:53 +0900 Subject: [PATCH] fix: preserve tied connection placements and show card rarity --- UPDATES.md | 10 +++- .../engine/board-connection-optimizer.test.ts | 50 +++++++++++++++++++ src/lib/engine/board-connection-optimizer.ts | 10 +++- src/lib/engine/maximum-weight-matching.ts | 24 +++++++-- src/routes/analysis/+page.svelte | 11 ++-- 5 files changed, 93 insertions(+), 12 deletions(-) diff --git a/UPDATES.md b/UPDATES.md index 3eafd32..e82c852 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,9 +1,17 @@ --- -updatedAt: 2026-09-06T10:13:00+09:00 +updatedAt: 2026-09-06T18:13:00+09:00 --- ## 2026년 9월 6일 +### 연결 카드 최적화의 동점 배치 유지 + +`FIXED` 일부 보드에서 파란 영역을 반대쪽으로 판정하던 오류를 수정했습니다. 액티브 발동률 UP 합계와 예상 점수가 모두 같으면 기존 연결 카드 배치를 우선 유지해 불필요한 교환을 줄입니다. + +### 연결 카드 최적화 성급 표시 + +`IMPROVED` 배치 분석의 연결 카드 최적화에서 변경 전·후 카드의 성급을 함께 표시합니다. 긴 카드명도 잘리지 않도록 줄바꿈하여 표시합니다. + ### 배치 분석 결과 저장과 성장 비교 `NEW` 배치 분석 결과와 당시 성장 상태를 저장하고 다시 열어볼 수 있습니다. 같은 편성을 현재 성장 상태와 비교해 종합력·평균 점수·고점의 변화를 확인할 수 있으며, 기록과 성장 데이터의 백업·가져오기를 지원합니다. diff --git a/src/lib/engine/board-connection-optimizer.test.ts b/src/lib/engine/board-connection-optimizer.test.ts index 8a14d6c..227e25f 100644 --- a/src/lib/engine/board-connection-optimizer.test.ts +++ b/src/lib/engine/board-connection-optimizer.test.ts @@ -180,6 +180,56 @@ test('우선 배치 가중치는 일반 점수보다 먼저 최대화한다', () assert.equal(result.placements.member['S-right'], 'left-card'); }); +test('발동률 합계와 점수가 같으면 멤버 순서와 무관하게 기존 파란 배치를 유지한다', () => { + const pairSource = { ...source, members: [ + { id: 'fubuki', positionGroupId: 'model' }, + { id: 'kobo', positionGroupId: 'model' } + ] }; + const currentPlacements = { fubuki: { 'S-right': leftCard.id }, kobo: { 'S-right': rightCard.id } }; + for (const members of [pairSource.members, [...pairSource.members].reverse()]) { + const result = optimizeBoardConnectionCards({ ...pairSource, members }, { + fubuki: ['S-right'], kobo: ['S-right'] + }, [leftCard, rightCard], { + currentPlacements, + preservePlacementOnTie: (_member, socket) => socket === 'S-right', + placementPriorityWeight: (card, _member, socket) => socket === 'S-right' ? (card.id === rightCard.id ? 2 : 1) : 0, + placementWeight: () => 1 + }); + assert.deepEqual(result.placements, currentPlacements); + assert.equal(new Set(result.assignments.map(({ cardId }) => cardId)).size, 2); + } +}); + +test('기존 파란 배치 유지보다 발동률 합계의 실제 증가를 우선한다', () => { + const result = optimizeBoardConnectionCards(source, { member: ['S-right'] }, [leftCard, rightCard], { + currentPlacements: { member: { 'S-right': leftCard.id } }, + preservePlacementOnTie: (_member, socket) => socket === 'S-right', + placementPriorityWeight: (card, _member, socket) => socket === 'S-right' ? (card.id === rightCard.id ? 2 : 1) : 0, + placementWeight: () => 1 + }); + assert.equal(result.placements.member['S-right'], rightCard.id); +}); + +test('발동률 합계가 같아도 예상 점수가 증가하면 기존 배치를 교환한다', () => { + const result = optimizeBoardConnectionCards(source, { member: ['S-right'] }, [leftCard, rightCard], { + currentPlacements: { member: { 'S-right': leftCard.id, 'S-center': rightCard.id } }, + preservePlacementOnTie: () => true, + placementPriorityWeight: (_card, _member, socket) => socket === 'S-right' ? 1 : 0, + placementWeight: (card, _member, socket) => socket === 'S-right' && card.id === rightCard.id ? 1.001 : 1 + }); + assert.deepEqual(result.placements, { member: { 'S-center': leftCard.id, 'S-right': rightCard.id } }); +}); + +test('파란 영역을 유지하면서 나머지 소켓은 점수로 최적화한다', () => { + const result = optimizeBoardConnectionCards(source, { member: ['S-right'] }, [leftCard, rightCard, { ...leftCard, id: 'better' }], { + currentPlacements: { member: { 'S-right': rightCard.id, 'S-center': leftCard.id } }, + preservePlacementOnTie: (_member, socket) => socket === 'S-right', + placementPriorityWeight: (_card, _member, socket) => socket === 'S-right' ? 1 : 0, + placementWeight: (card, _member, socket) => socket === 'S-center' && card.id === 'better' ? 100 : 1 + }); + assert.deepEqual(result.placements, { member: { 'S-center': 'better', 'S-right': rightCard.id } }); +}); + test('리더와 보너스 대상이 같으면 악곡 보너스보다 편성 효율을 우선한다', () => { const prioritySource: BoardConnectionOptimizerSource = { members: [{ id: 'leader', positionGroupId: 'model' }], diff --git a/src/lib/engine/board-connection-optimizer.ts b/src/lib/engine/board-connection-optimizer.ts index 748b8fb..a9ce196 100644 --- a/src/lib/engine/board-connection-optimizer.ts +++ b/src/lib/engine/board-connection-optimizer.ts @@ -53,6 +53,7 @@ export type BoardConnectionOptimizationOptions = { requireGroupSongBonus?: boolean; placementWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number; placementPriorityWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number; + preservePlacementOnTie?: (memberId: string, socketId: string) => boolean; prioritizePlacementWeight?: boolean; groupSingerMemberIds?: readonly string[]; }; @@ -164,6 +165,11 @@ export function optimizeBoardConnectionCards( )); const maximumAssignments = Math.min(cards.length, slots.length); const genericBudget = Math.max(1, ...genericWeights.flatMap((row) => row)) * maximumAssignments; + // Preserve user choices only when both priority and score are tied. + const retentionUnits = cards.map((card) => slots.map(({ member, socket }) => + options.preservePlacementOnTie?.(member.id, socket.groupId) + && options.currentPlacements?.[member.id]?.[socket.groupId] === card.id ? 1 : 0 + )); const weights = genericWeights.map((row, cardIndex) => row.map((weight, slotIndex) => priorityUnits[cardIndex][slotIndex] * (genericBudget + 1) + weight )); @@ -180,7 +186,7 @@ export function optimizeBoardConnectionCards( (options.requireSoloSongBonus ? soloBoosts[cardIndex][slotIndex] * ratio : 0) + (options.requireGroupSongBonus ? groupBoosts[cardIndex][slotIndex] : 0) ) * priorityMultiplier)); - const matches = maximumWeightMatching(prioritized); + const matches = maximumWeightMatching(prioritized, retentionUnits); const genericWeight = matches.reduce((total, match) => total + weights[match.rowIndex][match.columnIndex], 0); const soloBonus = baseSoloBonus + matches.reduce((total, match) => total + soloBoosts[match.rowIndex][match.columnIndex] / 1000, 0); const groupBonus = baseGroupBonus + matches.reduce((total, match) => total + groupBoosts[match.rowIndex][match.columnIndex] / 1000, 0); @@ -213,7 +219,7 @@ export function optimizeBoardConnectionCards( const key = JSON.stringify([ effect.effectPermilUp, effect.extent.map(({ x, y }) => `${x},${y}`).sort(), - genericWeights[cardIndex], priorityUnits[cardIndex], + genericWeights[cardIndex], priorityUnits[cardIndex], retentionUnits[cardIndex], soloBoosts[cardIndex], groupBoosts[cardIndex] ]); const group = equivalentCards.get(key) ?? []; diff --git a/src/lib/engine/maximum-weight-matching.ts b/src/lib/engine/maximum-weight-matching.ts index 46a7a17..c947092 100644 --- a/src/lib/engine/maximum-weight-matching.ts +++ b/src/lib/engine/maximum-weight-matching.ts @@ -8,7 +8,7 @@ export type WeightedMatch = { * Finds a maximum-weight one-to-one assignment for a rectangular matrix. * Rows or columns that cannot improve the result may remain unmatched. */ -export function maximumWeightMatching(weights: readonly (readonly number[])[]): WeightedMatch[] { +export function maximumWeightMatching(weights: readonly (readonly number[])[], tieWeights?: readonly (readonly number[])[]): WeightedMatch[] { const rowCount = weights.length; const columnCount = weights.reduce((maximum, row) => Math.max(maximum, row.length), 0); if (rowCount === 0 || columnCount === 0) return []; @@ -17,7 +17,10 @@ export function maximumWeightMatching(weights: readonly (readonly number[])[]): const transposed = Array.from({ length: columnCount }, (_, columnIndex) => Array.from({ length: rowCount }, (_, rowIndex) => weights[rowIndex]?.[columnIndex] ?? 0) ); - return maximumWeightMatching(transposed).map((match) => ({ + const transposedTies = tieWeights && Array.from({ length: columnCount }, (_, columnIndex) => + Array.from({ length: rowCount }, (_, rowIndex) => tieWeights[rowIndex]?.[columnIndex] ?? 0) + ); + return maximumWeightMatching(transposed, transposedTies).map((match) => ({ rowIndex: match.columnIndex, columnIndex: match.rowIndex, weight: match.weight @@ -27,6 +30,8 @@ export function maximumWeightMatching(weights: readonly (readonly number[])[]): const maximum = Math.max(0, ...weights.flatMap((row) => [...row])); const rowPotential = new Array(rowCount + 1).fill(0); const columnPotential = new Array(columnCount + 1).fill(0); + const rowTiePotential = new Array(rowCount + 1).fill(0); + const columnTiePotential = new Array(columnCount + 1).fill(0); const matchedRowByColumn = new Array(columnCount + 1).fill(0); const previousColumn = new Array(columnCount + 1).fill(0); @@ -34,24 +39,30 @@ export function maximumWeightMatching(weights: readonly (readonly number[])[]): matchedRowByColumn[0] = row; let currentColumn = 0; const minimum = new Array(columnCount + 1).fill(Number.POSITIVE_INFINITY); + const minimumTie = new Array(columnCount + 1).fill(Number.POSITIVE_INFINITY); const used = new Array(columnCount + 1).fill(false); do { used[currentColumn] = true; const currentRow = matchedRowByColumn[currentColumn]; let delta = Number.POSITIVE_INFINITY; + let deltaTie = Number.POSITIVE_INFINITY; let nextColumn = 0; for (let column = 1; column <= columnCount; column += 1) { if (used[column]) continue; const weight = weights[currentRow - 1]?.[column - 1] ?? 0; const cost = maximum - Math.max(0, weight); const reducedCost = cost - rowPotential[currentRow] - columnPotential[column]; - if (reducedCost < minimum[column]) { + const reducedTie = -(tieWeights?.[currentRow - 1]?.[column - 1] ?? 0) + - rowTiePotential[currentRow] - columnTiePotential[column]; + if (reducedCost < minimum[column] || (reducedCost === minimum[column] && reducedTie < minimumTie[column])) { minimum[column] = reducedCost; + minimumTie[column] = reducedTie; previousColumn[column] = currentColumn; } - if (minimum[column] < delta) { + if (minimum[column] < delta || (minimum[column] === delta && minimumTie[column] < deltaTie)) { delta = minimum[column]; + deltaTie = minimumTie[column]; nextColumn = column; } } @@ -59,8 +70,11 @@ export function maximumWeightMatching(weights: readonly (readonly number[])[]): if (used[column]) { rowPotential[matchedRowByColumn[column]] += delta; columnPotential[column] -= delta; + rowTiePotential[matchedRowByColumn[column]] += deltaTie; + columnTiePotential[column] -= deltaTie; } else { minimum[column] -= delta; + minimumTie[column] -= deltaTie; } } currentColumn = nextColumn; @@ -78,7 +92,7 @@ export function maximumWeightMatching(weights: readonly (readonly number[])[]): const row = matchedRowByColumn[column]; if (row === 0) continue; const weight = weights[row - 1]?.[column - 1] ?? 0; - if (weight > 0) matches.push({ rowIndex: row - 1, columnIndex: column - 1, weight }); + if (weight > 0 || (tieWeights?.[row - 1]?.[column - 1] ?? 0) > 0) matches.push({ rowIndex: row - 1, columnIndex: column - 1, weight }); } return matches; } diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index aeb0477..cf60b56 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -511,7 +511,9 @@ const socket = member ? (boardSummary.models as Record)[member.positionGroupId]?.find((position) => position.groupId === socketId) : null; - return (socket?.x ?? 0) < 0; + if (!member || !socket || socket.x === 0) return false; + const positions = (boardSummary.models as Record)[member.positionGroupId] ?? []; + return positions.some((position) => position.groupId.startsWith('B-') && position.x * socket.x > 0); } function totalActivationRateUp(context: ReturnType): number { @@ -600,6 +602,7 @@ await new Promise((resolve) => requestAnimationFrame(() => resolve())); const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, { currentPlacements: boardCards, + preservePlacementOnTie: () => true, placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score, placementPriorityWeight: (card, memberId, socketId) => isBlueConnectionSocket(memberId, socketId) ? placementMetrics(card, memberId, socketId).activationRateUp @@ -983,7 +986,7 @@
-

연결 카드 최적화

활성 노드는 유지하며, 파란색 영역은 액티브 발동률을 먼저 최대화한 뒤 예상 점수를 비교합니다.

+

연결 카드 최적화

활성 노드는 유지하며, 파란색 영역은 액티브 발동률 UP 합계를 먼저 최대화한 뒤 예상 점수를 비교합니다. 발동률 UP 합계와 예상 점수가 같으면 기존 연결 카드 배치를 우선 유지합니다.

{#if connectionOptimizationSnapshot}{/if} @@ -1009,7 +1012,7 @@ {@const member = boardMembers.find((item) => item.id === assignment.memberId)} {@const previousCard = assignment.previousCardId ? allCardById.get(assignment.previousCardId) : null} {@const card = assignment.cardId ? allCardById.get(assignment.cardId) : null} - {member?.name ?? assignment.memberId} · {connectionSocketName(assignment.memberId, assignment.socketId)}{previousCard ? cardLabel(previousCard) : '비어 있음'} → {card ? cardLabel(card) : '배치 해제'} + {member?.name ?? assignment.memberId} · {connectionSocketName(assignment.memberId, assignment.socketId)}{previousCard ? `${previousCard.rarity}★ ${cardLabel(previousCard)}` : '비어 있음'} → {card ? `${card.rarity}★ ${cardLabel(card)}` : '배치 해제'} {/each}
@@ -1197,7 +1200,7 @@ .connection-assignment-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 6px; max-height: 240px; overflow-y: auto; } .connection-assignment-list > span { display: grid; gap: 3px; padding: 8px 10px; border: 1px solid #304038; border-radius: 7px; background: #121915; } .connection-assignment-list small { color: #819287; font-size: 11px; } - .connection-assignment-list strong { overflow: hidden; color: #dbe7df; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } + .connection-assignment-list strong { color: #dbe7df; font-size: 12px; overflow-wrap: anywhere; } .connection-no-changes { margin: 0; padding: 10px 12px; border-radius: 8px; color: #a9cdb6; background: #18271d; font-size: 13px; font-weight: 750; } .connection-proposal .apply-connection { justify-self: end; } .empty { color: #858f9f; font-size: 14px; line-height: 1.65; }