diff --git a/UPDATES.md b/UPDATES.md index ff9096c..194c94e 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,9 +1,13 @@ --- -updatedAt: 2026-09-21T02:28:48Z +updatedAt: 2026-09-21T04:14:35Z --- ## 2026년 9월 21일 +### 중앙 연결 카드 교체 추천 수정 + +`FIX` 보유 카드 최적화에서 다른 카드의 이동 때문에 초기 추천 점수가 낮아지면, 이득인 중앙 연결 카드 교체까지 추천에서 빠지던 문제를 수정했습니다. 기존 배치로 돌아간 뒤에도 중앙 카드 교체·교환을 전체 배치 점수로 비교합니다. + ### 모바일 화면 사용성 개선 `IMPROVED` 전체 메뉴 버튼으로 모든 메뉴에 접근할 수 있습니다. 배치 분석·조합 탐색의 입력 항목 설명과 선택한 곡명·카드명을 좁은 화면에서도 확인할 수 있으며, 개화 버튼과 입력 영역을 넓혔습니다. 보드에 확대·축소·화면 맞춤 버튼을 추가하고, 연결 카드 선택 창은 화면 높이에 맞춰 목록 끝까지 스크롤할 수 있도록 개선했습니다. 분석 그래프의 작은 구간 대신 누를 수 있는 액티브 발동 목록도 제공합니다. 분석 설정 버튼은 본문을 가리지 않도록 페이지 아래로 옮겼습니다. diff --git a/src/lib/engine/board-connection-refinement.test.ts b/src/lib/engine/board-connection-refinement.test.ts index 23eec48..56c26fa 100644 --- a/src/lib/engine/board-connection-refinement.test.ts +++ b/src/lib/engine/board-connection-refinement.test.ts @@ -188,7 +188,55 @@ test('현재 배치가 더 좋으면 단독 평가 결과 대신 현재 배치 assert.deepEqual(refined, current); }); -test('54개 소켓과 카드 100장에서도 노란 소켓 주변만 정해진 횟수 안에서 비교한다', async () => { +test('중앙 카드 단독 점수가 같아도 전체 배치에서 점수가 오르는 미사용 카드를 추천한다', async () => { + const source: BoardConnectionOptimizerSource = { + members: [{ id: 'a', positionGroupId: 'model' }], + models: { model: [{ groupId: 'S-center', x: 0, y: 0 }, { groupId: 'S-side', x: 2, y: 0 }] }, + nodeGroups: { + 'S-center': [{ characterIds: [], type: 'connection', effect: null }], + 'S-side': [{ characterIds: [], type: 'connection', effect: null }] + } + }; + const boards = { a: ['S-side'] }; + const cards = ['old', 'new', 'anchor'].map((id) => ({ id, awakening: 0, + connect: [{ level: 1, effectPermilUp: 100, extent: [{ x: 1, y: 0 }] }] })); + const current = { a: { 'S-center': 'old', 'S-side': 'anchor' } }; + // Integer score rounding makes isolated gains tie, but not their combined gain. + const evaluate = (p: Record>) => ({ + score: 100 + Math.floor(((p.a?.['S-center'] === 'new' ? 2 : p.a?.['S-center'] === 'old' ? 1 : 0) + + (p.a?.['S-side'] === 'anchor' ? 8 : 0)) / 10), activationRateUp: 0 + }); + const initial = optimizeBoardConnectionCards(source, boards, cards, { + currentPlacements: current, preservePlacementOnTie: () => true, + placementWeight: (card, member, socket) => evaluate({ [member]: { [socket]: card.id } }).score - evaluate({}).score + }).placements; + assert.deepEqual(initial, current); + const refined = await refineBoardConnectionSongBonus(source, boards, cards, initial, { + currentPlacements: current, songBonusEnabled: false, evaluate + }); + assert.deepEqual(refined, { a: { 'S-center': 'new', 'S-side': 'anchor' } }); + assert.ok(evaluate(refined).score > evaluate(current).score); +}); + +test('초기 추천의 손해로 기존 배치로 돌아가도 중앙 신규 카드 교체를 다시 탐색한다', async () => { + const { source, boards, cards } = workFixture(false); + cards.push({ ...cards[1], id: 'new' }); + const current = { work: { S: 'worker' }, score: { S: 'spare' } }; + const initial = { other: { S: 'worker' }, score: { S: 'new' } }; + const evaluate = (p: Record>) => ({ + score: 100 + (p.work?.S === 'worker' ? 50 : 0) + (p.score?.S === 'new' ? 10 : 0), + activationRateUp: 0 + }); + assert.ok(evaluate(initial).score < evaluate(current).score); + const refined = await refineBoardConnectionSongBonus(source, boards, cards, initial, { + currentPlacements: current, songBonusEnabled: false, evaluate + }); + assert.equal(refined.work.S, 'worker'); + assert.equal(refined.score.S, 'new'); + assert.ok(evaluate(refined).score > evaluate(current).score); +}); + +test('54개 소켓과 카드 100장에서도 정해진 횟수 안에서 비교한다', async () => { const { source, boards, cards, evaluate, singers } = fixture(); for (let index = 0; index < 51; index += 1) { const id = `green-${index}`; @@ -203,5 +251,5 @@ test('54개 소켓과 카드 100장에서도 노란 소켓 주변만 정해진 evaluate: (placements) => { checks += 1; return evaluate(placements); } }); assert.ok(evaluate(refined).score >= evaluate(initial).score); - assert.ok(checks <= 1 + 3 * 2 * (54 + 100 + 1)); + assert.ok(checks <= 1 + 3 * 54 * (54 + 100 + 1)); }); diff --git a/src/lib/engine/board-connection-song-refinement.ts b/src/lib/engine/board-connection-song-refinement.ts index 444588f..3093812 100644 --- a/src/lib/engine/board-connection-song-refinement.ts +++ b/src/lib/engine/board-connection-song-refinement.ts @@ -4,8 +4,9 @@ import type { BoardConnectionOptimizerSource, OptimizableConnectionCard } from ' type Placements = Record>; type Metric = { score: number; activationRateUp: number }; -/** Bounded local correction for song-bonus saturation, not a global search. - * Keep the original matching result and try moves/swaps involving song/work sockets. +/** Bounded local correction for non-additive board scores, not a global search. + * Try moves/swaps involving center, song and work sockets, including when the + * original matching result loses to the current placements. * Every comparison scores the whole board, including caps and rounding. */ export async function refineBoardConnectionSongBonus( @@ -53,7 +54,8 @@ export async function refineBoardConnectionSongBonus( })); return { member: member.id, socket: socket.groupId, required: required.length > 0, preserveWork, allowed: cards.map((card) => required.every((position) => covers(card, position))), - focus: cards.some((card) => yellow.some((position) => covers(card, position))) }; + focus: (socket.x === 0 && socket.y === 0) + || cards.some((card) => yellow.some((position) => covers(card, position))) }; }); }); const encode = (assignment: number[]) => { @@ -83,7 +85,9 @@ export async function refineBoardConnectionSongBonus( let metric = evaluate(best); if (current && valid(current)) { const value = evaluate(current); if (better(value, metric)) { best = current; metric = value; } } const focus = slots.flatMap((slot, index) => slot.focus || slot.preserveWork ? [index] : []); - // At most three search passes over song and existing work sockets. + // Center upgrades must also be checked against the complete board: isolated + // matching weights can overvalue other moves and cause fallback to current. + // At most three passes over center, song and existing work sockets. for (let pass = 0; pass < 3 && focus.length; pass += 1) { let next = best; let nextMetric = metric; diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index 33dae09..4b417fa 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -745,7 +745,7 @@ return { score: referenceScore(context), activationRateUp: totalActivationRateUp(context) }; }, onProgress: async (checks) => { - connectionOptimizationProgress = { stage: '노란 보너스 상한 보정 · ' + checks.toLocaleString('ko-KR') + '회 비교', completed: 0, total: 0 }; + connectionOptimizationProgress = { stage: '중앙 교체·노란 보너스 보정 · ' + checks.toLocaleString('ko-KR') + '회 비교', completed: 0, total: 0 }; await new Promise((resolve) => requestAnimationFrame(() => resolve())); } }); @@ -1168,7 +1168,7 @@
-

연결 카드 최적화

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

+

연결 카드 최적화

활성 노드는 유지하며, 편성 멤버의 파란 영역 연결 카드는 반드시 인근의 큰 발동률 노드를 포함합니다. 편성 밖 멤버의 보드도 현재 편성의 점수에 기여하는 효과는 최적화에 반영합니다. 빠른 배정 후 중앙 카드 교체·교환과 노란 보너스 상한에 따른 이동·교환을 전체 배치 점수로 최대 3차례 보정하며, 전역 최적해를 보장하지 않습니다. 초기 추천보다 기존 배치가 좋으면 기존 배치에서 개선을 탐색합니다. 이 조건 안에서 최고점을 최우선으로 비교하고 최고점이 같으면 액티브 발동률 UP 합계를 비교합니다. 두 값이 모두 같으면 기존 연결 카드 배치를 우선 유지합니다. 조건을 충족할 수 없거나 최종 검증에서 최고점이 낮아지면 기존 배치를 유지합니다.

{#if connectionOptimizationSnapshot}{/if}