From 96bd5c4514d8ebd43ecc750953117b529fba482e Mon Sep 17 00:00:00 2001 From: dal4segno Date: Wed, 9 Sep 2026 12:08:42 +0900 Subject: [PATCH] fix: scope blue board coverage to selected unit members --- UPDATES.md | 6 ++- .../engine/board-connection-optimizer.test.ts | 45 +++++++++++++++++++ src/lib/engine/board-connection-optimizer.ts | 7 ++- src/routes/analysis/+page.svelte | 4 +- 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/UPDATES.md b/UPDATES.md index ad3a71e..b2749ac 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,9 +1,13 @@ --- -updatedAt: 2026-09-09T00:00:00+09:00 +updatedAt: 2026-09-09T12:00:00+09:00 --- ## 2026년 9월 9일 +### 연결 카드 최적화의 편성 대상 수정 + +`FIXED` 배치 분석의 연결 카드 최적화에서 편성에 없는 멤버의 파란 보드에도 카드를 필수 배치하던 문제를 수정했습니다. 큰 발동률 노드를 포함하는 필수 배치 조건은 편성 멤버에게만 적용하며, 편성 밖 보드의 전체 능력치·가창 보너스 등 점수에 기여하는 효과는 계속 반영합니다. + ### Illusion Night 악곡 추가 `DATA` 파볼리아 레이네의 Illusion Night를 추가했습니다. Easy·Normal·Hard·Expert 채보와 곡별 점수 계산을 지원합니다. diff --git a/src/lib/engine/board-connection-optimizer.test.ts b/src/lib/engine/board-connection-optimizer.test.ts index 1631739..3770924 100644 --- a/src/lib/engine/board-connection-optimizer.test.ts +++ b/src/lib/engine/board-connection-optimizer.test.ts @@ -94,6 +94,51 @@ test('큰 노드를 덮는 카드가 부족하면 파란 소켓을 비우거나 } }); +test('편성 밖 파란 소켓은 필수 배치에서 제외하고 여분 카드로도 빈 슬롯을 채우지 않는다', () => { + for (const side of [-1, 1]) { + const scopedSource = blueSource(side); + scopedSource.members = ['okayu', 'korone', 'member'].map((id) => ({ id, positionGroupId: 'model' })); + const boards = Object.fromEntries(scopedSource.members.map(({ id }) => [id, ['S-blue', 'B-large']])); + const coveringCard = { ...rightCard, connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: -side * 2, y: 0 }] }] }; + for (const cards of [[coveringCard], [coveringCard, { ...coveringCard, id: 'spare' }]]) { + const result = optimizeBoardConnectionCards(scopedSource, boards, cards, { + requireBlueActivationCoverage: ['member'], + preservePlacementOnTie: () => true, + placementWeight: () => 0, + placementTieWeight: (_card, memberId, socketId) => memberId === 'member' && socketId === 'S-blue' ? 6 : 0 + }); + assert.equal(result.assignments.length, 1); + assert.equal(result.assignments[0].memberId, 'member'); + assert.equal(result.assignments[0].socketId, 'S-blue'); + } + assert.throws(() => optimizeBoardConnectionCards(scopedSource, boards, [], { + requireBlueActivationCoverage: ['member'], placementWeight: () => 0 + }), /큰 발동률 노드를 포함할 수 없어/); + } +}); + +test('편성 밖 보드의 점수 기여와 동률인 기존 배치는 유지한다', () => { + const scopedSource = blueSource(1); + scopedSource.members = ['member', 'outsider'].map((id) => ({ id, positionGroupId: 'model' })); + const cards = ['required', 'global', 'existing'].map((id) => ({ + id, awakening: 0, connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: -2, y: 0 }] }] + })); + const result = optimizeBoardConnectionCards(scopedSource, { + member: ['S-blue', 'B-large'], outsider: ['S-blue', 'B-large'] + }, cards, { + requireBlueActivationCoverage: ['member'], + currentPlacements: { outsider: { 'S-blue': 'existing' } }, + preservePlacementOnTie: () => true, + placementWeight: (card, memberId, socketId) => + card.id === 'global' && memberId === 'outsider' && socketId === 'S-center' ? 100 : 0, + placementTieWeight: (_card, memberId, socketId) => memberId === 'member' && socketId === 'S-blue' ? 6 : 0 + }); + assert.deepEqual(result.placements, { + member: { 'S-blue': 'required' }, outsider: { 'S-center': 'global', 'S-blue': 'existing' } + }); + assert.equal(new Set(result.assignments.map(({ cardId }) => cardId)).size, 3); +}); + test('모양과 수치가 같은 카드는 기존 배치를 유지하고 중복 배치하지 않는다', () => { const cards = ['a', 'b', 'c'].map((id) => ({ ...rightCard, id })); const currentPlacements = { member: { 'S-center': 'c', 'S-right': 'a' } }; diff --git a/src/lib/engine/board-connection-optimizer.ts b/src/lib/engine/board-connection-optimizer.ts index 1e44369..b8eb233 100644 --- a/src/lib/engine/board-connection-optimizer.ts +++ b/src/lib/engine/board-connection-optimizer.ts @@ -52,7 +52,8 @@ export type BoardConnectionOptimizationOptions = { targetMemberId?: string; requireSoloSongBonus?: boolean; requireGroupSongBonus?: boolean; - requireBlueActivationCoverage?: boolean; + /** true covers every member; a member ID list scopes coverage to the current unit. */ + requireBlueActivationCoverage?: boolean | readonly string[]; placementWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number; placementPriorityWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number; placementTieWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number; @@ -137,7 +138,9 @@ export function optimizeBoardConnectionCards( // 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 + (options.requireBlueActivationCoverage === true + || (Array.isArray(options.requireBlueActivationCoverage) && options.requireBlueActivationCoverage.includes(member.id))) + && socket.x !== 0 ? positions.filter((position) => { const node = nodeForMember(source, position.groupId, member.id); return position.groupId.startsWith('B-') && position.x * socket.x > 0 diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index 2e96f94..62fb35b 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -687,7 +687,7 @@ await new Promise((resolve) => requestAnimationFrame(() => resolve())); const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, { currentPlacements: boardCards, - requireBlueActivationCoverage: true, + requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId), preservePlacementOnTie: () => true, placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score, placementTieWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).activationRateUp @@ -1104,7 +1104,7 @@
-

연결 카드 최적화

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

+

연결 카드 최적화

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

{#if connectionOptimizationSnapshot}{/if}