diff --git a/UPDATES.md b/UPDATES.md index 5b5f212..2033632 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,7 +1,13 @@ --- -updatedAt: 2026-09-07T14:17:02+09:00 +updatedAt: 2026-09-08T12:00:00+09:00 --- +## 2026년 9월 8일 + +### 연결 카드 최적화의 최고점 우선 적용 + +`FIXED` 배치 분석의 연결 카드 최적화에서 파란 영역의 연결 카드가 인근의 큰 발동률 노드를 반드시 포함하도록 했습니다. 이 조건 안에서 최고점을 최우선으로 비교하고, 최고점이 같을 때 액티브 발동률 UP 합계를 비교합니다. 조건을 충족할 수 없거나 최종 재계산에서 기존 배치보다 최고점이 낮아지면 기존 배치를 유지합니다. + ## 2026년 9월 7일 ### 그래프 시간축 갱신 수정 diff --git a/src/lib/engine/board-connection-optimizer.test.ts b/src/lib/engine/board-connection-optimizer.test.ts index 227e25f..1631739 100644 --- a/src/lib/engine/board-connection-optimizer.test.ts +++ b/src/lib/engine/board-connection-optimizer.test.ts @@ -31,6 +31,69 @@ const rightCard = { connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }] }; +function blueSource(side: number): BoardConnectionOptimizerSource { + return { + members: [{ id: 'member', positionGroupId: 'model' }], + models: { model: [ + { groupId: 'S-center', x: 0, y: 0 }, + { groupId: 'S-blue', x: side * 7, y: 0 }, + { groupId: 'B-large', x: side * 5, y: 0 }, + { groupId: 'B-small', x: side * 9, y: 0 } + ] }, + nodeGroups: { + 'S-center': [{ characterIds: [], type: 'connection', effect: null }], + 'S-blue': [{ characterIds: [], type: 'connection', effect: null }], + 'B-large': [{ characterIds: [], type: 'card', grade: 2, + effect: { type: 'live_active_skill_activation_probability_up_permil_up', value: 60 } }], + 'B-small': [{ characterIds: [], type: 'card', grade: 1, + effect: { type: 'live_active_skill_activation_probability_up_permil_up', value: 20 } }] + } + }; +} + +test('좌우 파란 소켓 모두 작은 노드만 덮는 고득점 카드 대신 큰 발동률 노드를 포함한다', () => { + for (const side of [-1, 1]) { + const cards = [ + { ...leftCard, id: 'covers-large', connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: -side * 2, y: 0 }] }] }, + { ...rightCard, id: 'covers-small', connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: side * 2, y: 0 }] }] } + ]; + const result = optimizeBoardConnectionCards(blueSource(side), { member: ['S-blue', 'B-large', 'B-small'] }, cards, { + requireBlueActivationCoverage: true, + currentPlacements: { member: { 'S-blue': 'covers-small' } }, + preservePlacementOnTie: () => true, + placementWeight: (card, _member, socket) => card.id === 'covers-small' && socket === 'S-blue' ? 10000 : 1, + placementTieWeight: (card) => card.id === 'covers-small' ? 100 : 0 + }); + assert.equal(result.placements.member['S-blue'], 'covers-large'); + assert.equal(result.placements.member['S-center'], 'covers-small'); + } +}); + +test('큰 노드의 활성 여부와 관계없이 개화 단계의 실제 범위로 포함 조건을 검사한다', () => { + const card = { id: 'awakened', awakening: 5, connect: [ + { level: 1, effectPermilUp: 1000, extent: [{ x: -2, y: 0 }] }, + { level: 2, effectPermilUp: 1500, extent: [{ x: 2, y: 0 }] } + ] }; + const result = optimizeBoardConnectionCards(blueSource(-1), { member: ['S-blue'] }, [card], { + requireBlueActivationCoverage: true, placementWeight: () => 0 + }); + assert.equal(result.placements.member['S-blue'], card.id); + assert.throws(() => optimizeBoardConnectionCards(blueSource(-1), { member: ['S-blue'] }, [{ ...card, awakening: 0 }], { + requireBlueActivationCoverage: true + }), /큰 발동률 노드를 포함할 수 없어/); +}); + +test('큰 노드를 덮는 카드가 부족하면 파란 소켓을 비우거나 카드를 중복 사용하지 않는다', () => { + const pair = blueSource(-1); + pair.members = [...pair.members, { id: 'other', positionGroupId: 'model' }]; + const card = { ...rightCard, connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 2, y: 0 }] }] }; + for (const cards of [[], [card]]) { + assert.throws(() => optimizeBoardConnectionCards(pair, { member: ['S-blue'], other: ['S-blue'] }, cards, { + requireBlueActivationCoverage: true + }), /큰 발동률 노드를 포함할 수 없어/); + } +}); + test('모양과 수치가 같은 카드는 기존 배치를 유지하고 중복 배치하지 않는다', () => { const cards = ['a', 'b', 'c'].map((id) => ({ ...rightCard, id })); const currentPlacements = { member: { 'S-center': 'c', 'S-right': 'a' } }; @@ -180,6 +243,40 @@ test('우선 배치 가중치는 일반 점수보다 먼저 최대화한다', () assert.equal(result.placements.member['S-right'], 'left-card'); }); +test('최고점 우선 최적화는 발동률이 낮아져도 더 높은 점수를 선택한다', () => { + const result = optimizeBoardConnectionCards(source, { member: [] }, [leftCard, rightCard], { + currentPlacements: { member: { 'S-center': leftCard.id } }, + preservePlacementOnTie: () => true, + placementWeight: (card) => card.id === rightCard.id ? 1.001 : 1, + placementTieWeight: (card) => card.id === leftCard.id ? 100 : 0 + }); + assert.equal(result.placements.member['S-center'], rightCard.id); +}); + +test('최고점이 같으면 발동률을 높이고 두 값이 같으면 기존 배치를 유지한다', () => { + for (const increase of [0, 0.001]) { + const currentPlacements = { member: { 'S-center': leftCard.id } }; + const result = optimizeBoardConnectionCards(source, { member: [] }, [rightCard, leftCard], { + currentPlacements, + preservePlacementOnTie: () => true, + placementWeight: () => 100, + placementTieWeight: (card) => card.id === rightCard.id ? 1 + increase : 1 + }); + assert.equal(result.placements.member['S-center'], increase > 0 ? rightCard.id : leftCard.id); + } +}); + +test('발동률이 높아져도 기존 배치보다 최고점이 낮은 카드는 선택하지 않는다', () => { + const currentPlacements = { member: { 'S-center': leftCard.id } }; + const result = optimizeBoardConnectionCards(source, { member: [] }, [rightCard, leftCard], { + currentPlacements, + preservePlacementOnTie: () => true, + placementWeight: (card) => card.id === leftCard.id ? 101 : 100, + placementTieWeight: (card) => card.id === rightCard.id ? 100 : 0 + }); + assert.deepEqual(result.placements, currentPlacements); +}); + test('발동률 합계와 점수가 같으면 멤버 순서와 무관하게 기존 파란 배치를 유지한다', () => { const pairSource = { ...source, members: [ { id: 'fubuki', positionGroupId: 'model' }, diff --git a/src/lib/engine/board-connection-optimizer.ts b/src/lib/engine/board-connection-optimizer.ts index a9ce196..1e44369 100644 --- a/src/lib/engine/board-connection-optimizer.ts +++ b/src/lib/engine/board-connection-optimizer.ts @@ -9,6 +9,7 @@ type OptimizableBoardEffect = { type OptimizableBoardNode = { characterIds: readonly string[]; + grade?: number; type: string; effect: OptimizableBoardEffect | null; }; @@ -51,8 +52,10 @@ export type BoardConnectionOptimizationOptions = { targetMemberId?: string; requireSoloSongBonus?: boolean; requireGroupSongBonus?: boolean; + requireBlueActivationCoverage?: boolean; placementWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number; placementPriorityWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number; + placementTieWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number; preservePlacementOnTie?: (memberId: string, socketId: string) => boolean; prioritizePlacementWeight?: boolean; groupSingerMemberIds?: readonly string[]; @@ -131,13 +134,32 @@ export function optimizeBoardConnectionCards( .map((socket) => ({ member, socket, positions, active })); }); + // Large blue activation nodes must be in the card's actual extent, even + // when inactive. Identify the blue side from coordinates, not left/right. + const requiredNodes = slots.map(({ member, socket, positions }) => + options.requireBlueActivationCoverage && socket.x !== 0 + ? positions.filter((position) => { + const node = nodeForMember(source, position.groupId, member.id); + return position.groupId.startsWith('B-') && position.x * socket.x > 0 + && (node?.grade ?? 0) >= 2 + && node?.effect?.type === 'live_active_skill_activation_probability_up_permil_up'; + }) + : [] + ); + const coverageError = () => new Error('보유 연결 카드로 모든 파란 연결 노드의 큰 발동률 노드를 포함할 수 없어 기존 배치를 유지합니다.'); const cards = ownedCards.filter((card) => selectedEffect(card)); const emptyResult = { placements: {}, assignments: [], totalWeight: 0, soloSongBonusPercent: Math.min(10, baseSoloBonus / 10), groupSongBonusPercent: Math.min(10, baseGroupBonus / 10) }; + if (cards.length === 0 && requiredNodes.some((nodes) => nodes.length > 0)) throw coverageError(); if (cards.length === 0 || slots.length === 0) return emptyResult; + const allowed = cards.map((card) => slots.map(({ socket }, slotIndex) => + requiredNodes[slotIndex].every((node) => selectedEffect(card)!.extent.some((offset) => + socket.x + offset.x === node.x && socket.y + offset.y === node.y + )) + )); const soloBoosts = cards.map(() => slots.map(() => 0)); const groupBoosts = cards.map(() => slots.map(() => 0)); @@ -170,6 +192,12 @@ export function optimizeBoardConnectionCards( options.preservePlacementOnTie?.(member.id, socket.groupId) && options.currentPlacements?.[member.id]?.[socket.groupId] === card.id ? 1 : 0 )); + // Compare secondary effects only on an exact primary-score tie. Integer + // permil units leave retention strictly behind any activation-rate increase. + const tieWeights = cards.map((card, cardIndex) => slots.map(({ member, socket }, slotIndex) => + Math.max(0, Math.round((options.placementTieWeight?.(card, member.id, socket.groupId) ?? 0) * 1000)) + * (maximumAssignments + 1) + retentionUnits[cardIndex][slotIndex] + )); const weights = genericWeights.map((row, cardIndex) => row.map((weight, slotIndex) => priorityUnits[cardIndex][slotIndex] * (genericBudget + 1) + weight )); @@ -186,7 +214,20 @@ export function optimizeBoardConnectionCards( (options.requireSoloSongBonus ? soloBoosts[cardIndex][slotIndex] * ratio : 0) + (options.requireGroupSongBonus ? groupBoosts[cardIndex][slotIndex] : 0) ) * priorityMultiplier)); - const matches = maximumWeightMatching(prioritized, retentionUnits); + // Satisfy every required socket before maximizing score. Disallowed edges + // have no weight or tie value and are never returned as placements. + const coverageBonus = Math.max(1, ...prioritized.flat()) * maximumAssignments + 1; + const constrained = prioritized.map((row, cardIndex) => row.map((weight, slotIndex) => + !allowed[cardIndex][slotIndex] ? 0 + : weight + (requiredNodes[slotIndex].length > 0 ? coverageBonus : 0) + )); + const constrainedTies = tieWeights.map((row, cardIndex) => row.map((weight, slotIndex) => + allowed[cardIndex][slotIndex] ? weight : 0 + )); + const matches = maximumWeightMatching(constrained, constrainedTies) + .filter((match) => allowed[match.rowIndex][match.columnIndex]); + if (requiredNodes.some((nodes, slotIndex) => nodes.length > 0 + && !matches.some((match) => match.columnIndex === slotIndex))) throw coverageError(); 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); @@ -219,7 +260,7 @@ export function optimizeBoardConnectionCards( const key = JSON.stringify([ effect.effectPermilUp, effect.extent.map(({ x, y }) => `${x},${y}`).sort(), - genericWeights[cardIndex], priorityUnits[cardIndex], retentionUnits[cardIndex], + genericWeights[cardIndex], priorityUnits[cardIndex], tieWeights[cardIndex], allowed[cardIndex], soloBoosts[cardIndex], groupBoosts[cardIndex] ]); const group = equivalentCards.get(key) ?? []; diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index 447c7c2..f59d1c0 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -550,16 +550,6 @@ return socket.y > 0 ? '위쪽' : '아래쪽'; } - function isBlueConnectionSocket(memberId: string, socketId: string): boolean { - const member = boardMembers.find((item) => item.id === memberId); - const socket = member - ? (boardSummary.models as Record)[member.positionGroupId]?.find((position) => position.groupId === socketId) - : null; - 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 { return Object.values(context.boardEffects.activationRateUpByCardId).reduce((total, value) => total + value, 0); } @@ -598,7 +588,7 @@ perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio), directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent } - ).expectedScore; + ).theoreticalMaxScore; const emptyScore = referenceScore(emptyContext); const emptyActivationRateUp = totalActivationRateUp(emptyContext); const beforeActivationRateUp = totalActivationRateUp(beforeContext); @@ -646,11 +636,10 @@ await new Promise((resolve) => requestAnimationFrame(() => resolve())); const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, { currentPlacements: boardCards, + requireBlueActivationCoverage: true, preservePlacementOnTie: () => true, placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score, - placementPriorityWeight: (card, memberId, socketId) => isBlueConnectionSocket(memberId, socketId) - ? placementMetrics(card, memberId, socketId).activationRateUp - : 0 + placementTieWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).activationRateUp }); if (optimized.assignments.length === 0) throw new Error('현재 활성 노드에 효과를 주는 연결 카드 배치를 찾지 못했습니다.'); connectionOptimizationProgress = { stage: '최종 점수 검증 중', completed: totalPlacementChecks, total: totalPlacementChecks }; @@ -661,11 +650,11 @@ directSongScoreBonusPercent: afterContext.boardEffects.directSongScoreBonusPercent }); const afterActivationRateUp = totalActivationRateUp(afterContext); - if (afterActivationRateUp + 1e-9 < beforeActivationRateUp) { - throw new Error('현재 배치보다 액티브 스킬 발동률 증가량이 낮아 기존 배치를 유지합니다.'); + if (after.theoreticalMaxScore < analysisResult.theoreticalMaxScore) { + throw new Error(`현재 배치의 최고점(${analysisResult.theoreticalMaxScore.toLocaleString('ko-KR')})이 탐색 결과보다 높아 기존 배치를 유지합니다.`); } - if (Math.abs(afterActivationRateUp - beforeActivationRateUp) < 1e-9 && after.expectedScore < analysisResult.expectedScore) { - throw new Error(`현재 배치의 예상 점수(${analysisResult.expectedScore.toLocaleString('ko-KR')})가 탐색 결과보다 높아 기존 배치를 유지합니다.`); + if (after.theoreticalMaxScore === analysisResult.theoreticalMaxScore && afterActivationRateUp + 1e-9 < beforeActivationRateUp) { + throw new Error('최고점이 같고 액티브 스킬 발동률 증가량이 낮아 기존 배치를 유지합니다.'); } const changedAssignments = boardMembers.flatMap((member) => { const before = boardCards[member.id] ?? {}; @@ -1063,7 +1052,7 @@
-

연결 카드 최적화

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

+

연결 카드 최적화

활성 노드는 유지하며, 파란 영역의 연결 카드는 반드시 인근의 큰 발동률 노드를 포함합니다. 이 조건 안에서 최고점을 최우선으로 비교하고 최고점이 같으면 액티브 발동률 UP 합계를 비교합니다. 두 값이 모두 같으면 기존 연결 카드 배치를 우선 유지합니다. 조건을 충족할 수 없거나 최종 검증에서 최고점이 낮아지면 기존 배치를 유지합니다.

{#if connectionOptimizationSnapshot}{/if}