diff --git a/UPDATES.md b/UPDATES.md index 3205585..7d834d9 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,7 +1,13 @@ --- -updatedAt: 2026-09-18T03:36:33.635Z +updatedAt: 2026-09-20T04:48:31Z --- +## 2026년 9월 20일 + +### 홀로워크용 연결 카드 유지 + +`IMPROVED` 연결 카드 최적화에서 활성 노란 홀로워크 노드를 증폭 중인 카드는 이론상 고점을 높이는 데 필요한 경우에만 이동합니다. 점수가 같으면 발동률 증가보다 기존 배치를 우선하며, 다른 카드의 점수 개선 때문에 불필요하게 이동한 홀로워크 카드도 복원합니다. + ## 2026년 9월 18일 ### 홀로워크 조합 추천과 목록 개선 diff --git a/src/lib/engine/board-connection-refinement.test.ts b/src/lib/engine/board-connection-refinement.test.ts index 1de65d6..23eec48 100644 --- a/src/lib/engine/board-connection-refinement.test.ts +++ b/src/lib/engine/board-connection-refinement.test.ts @@ -35,6 +35,65 @@ function fixture(sisters = false) { return { source, boards, cards, evaluate, singers }; } +function workFixture(active = true) { + const source: BoardConnectionOptimizerSource = { + members: ['work', 'score', 'other'].map((id) => ({ id, positionGroupId: id })), + models: Object.fromEntries(['work', 'score', 'other'].map((id) => [id, [ + { groupId: 'S', x: 0, y: 0 }, { groupId: 'Y-work', x: 1, y: 0 } + ]])), + nodeGroups: { + S: [{ characterIds: [], type: 'connection', effect: null }], + 'Y-work': [{ characterIds: [], type: 'normal', effect: { + trigger: 'work', type: 'work_reward_quantity_up_permil_up', value: 100 + } }] + } + }; + const boards = { work: active ? ['Y-work'] : [], score: [], other: [] }; + const cards = ['worker', 'spare'].map((id) => ({ id, awakening: 0, + connect: [{ level: 1, effectPermilUp: 100, extent: [{ x: 1, y: 0 }] }] })); + return { source, boards, cards, current: { work: { S: 'worker' } } }; +} + +test('홀로워크 카드는 점수가 같으면 발동률 증가보다 기존 배치를 우선한다', async () => { + const { source, boards, cards, current } = workFixture(); + const result = await refineBoardConnectionSongBonus(source, boards, cards, { score: { S: 'worker' } }, { + currentPlacements: current, songBonusEnabled: false, + evaluate: (p) => ({ score: 100, activationRateUp: p.score?.S === 'worker' ? 50 : 0 }) + }); + assert.equal(result.work.S, 'worker'); +}); + +test('홀로워크 카드 자체가 점수를 높일 때는 이동을 허용한다', async () => { + const { source, boards, cards, current } = workFixture(); + const result = await refineBoardConnectionSongBonus(source, boards, cards, current, { + currentPlacements: current, + evaluate: (p) => ({ score: p.score?.S === 'worker' ? 101 : 100, activationRateUp: 0 }) + }); + assert.equal(result.score.S, 'worker'); +}); + +test('다른 카드의 점수 개선은 유지하며 불필요하게 이동한 홀로워크 카드만 복원한다', async () => { + const { source, boards, cards, current } = workFixture(); + const result = await refineBoardConnectionSongBonus(source, boards, cards, + { score: { S: 'spare' }, other: { S: 'worker' } }, { + currentPlacements: current, + evaluate: (p) => ({ score: p.score?.S === 'spare' ? 200 : 100, activationRateUp: p.other ? 50 : 0 }) + }); + assert.equal(result.work.S, 'worker'); + assert.equal(result.score.S, 'spare'); + const ids = Object.values(result).flatMap(Object.values); + assert.equal(new Set(ids).size, ids.length); +}); + +test('활성 홀로워크 노드가 없으면 기존 발동률 동점 기준을 유지한다', async () => { + const { source, boards, cards, current } = workFixture(false); + const result = await refineBoardConnectionSongBonus(source, boards, cards, { score: { S: 'worker' } }, { + currentPlacements: current, + evaluate: (p) => ({ score: 100, activationRateUp: p.score ? 50 : 0 }) + }); + assert.equal(result.score.S, 'worker'); +}); + for (const sisters of [false, true]) { test(`${sisters ? '후와모코' : '유닛'} 보너스 상한을 넘기는 노란 카드를 다른 보드로 이동한다`, async () => { const { source, boards, cards, evaluate, singers } = fixture(sisters); diff --git a/src/lib/engine/board-connection-song-refinement.ts b/src/lib/engine/board-connection-song-refinement.ts index 3ffdaa3..444588f 100644 --- a/src/lib/engine/board-connection-song-refinement.ts +++ b/src/lib/engine/board-connection-song-refinement.ts @@ -5,7 +5,7 @@ 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 sockets. + * Keep the original matching result and try moves/swaps involving song/work sockets. * Every comparison scores the whole board, including caps and rounding. */ export async function refineBoardConnectionSongBonus( @@ -44,7 +44,14 @@ export async function refineBoardConnectionSongBonus( && value?.trigger !== 'work' && value?.type === 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type' && (!options.song || songBonusApplies(value, member.id, options.song)); }); - return { member: member.id, socket: socket.groupId, required: required.length > 0, + const currentCard = cards.find((card) => card.id === options.currentPlacements?.[member.id]?.[socket.groupId]); + const preserveWork = Boolean(currentCard && effect(currentCard)!.effectPermilUp > 0 && positions.some((position) => { + const value = node(position.groupId, member.id)?.effect; + return active.has(position.groupId) && position.groupId.startsWith('Y-') + && value?.trigger === 'work' && value.type === 'work_reward_quantity_up_permil_up' + && (value.value ?? 0) > 0 && covers(currentCard, position); + })); + 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))) }; }); @@ -63,17 +70,20 @@ export async function refineBoardConnectionSongBonus( const metric = options.evaluate(encode(assignment)); if (!Number.isFinite(metric.score) || !Number.isFinite(metric.activationRateUp)) throw new Error('연결 카드 배치 평가값이 유효하지 않습니다.'); checks += 1; - return { ...metric, retained: current ? assignment.filter((index, slot) => index === current[slot]).length : 0 }; + return { ...metric, + workRetained: current ? assignment.filter((index, slot) => slots[slot].preserveWork && index === current[slot]).length : 0, + retained: current ? assignment.filter((index, slot) => index === current[slot]).length : 0 }; }; type Ranked = ReturnType; const better = (a: Ranked, b: Ranked) => a.score > b.score || (a.score === b.score - && (a.activationRateUp > b.activationRateUp || (a.activationRateUp === b.activationRateUp && a.retained > b.retained))); + && (a.workRetained > b.workRetained || (a.workRetained === b.workRetained + && (a.activationRateUp > b.activationRateUp || (a.activationRateUp === b.activationRateUp && a.retained > b.retained))))); let best = decode(initial); if (!valid(best)) throw new Error('연결 카드 초기 배치가 필수 범위 조건을 충족하지 않습니다.'); 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 ? [index] : []); - // At most three passes: O(3 * yellow sockets * (all sockets + owned cards)). + const focus = slots.flatMap((slot, index) => slot.focus || slot.preserveWork ? [index] : []); + // At most three search passes over song and existing work sockets. for (let pass = 0; pass < 3 && focus.length; pass += 1) { let next = best; let nextMetric = metric; @@ -102,6 +112,28 @@ export async function refineBoardConnectionSongBonus( if (next === best) break; best = next; metric = nextMetric; } + // Restore each work card independently: unrelated score gains must not justify + // moving it. Swap with its destination (or replace an unused card), never duplicate. + // Repeat when another restoration makes a previously rejected swap possible. + if (current) { + let restored = true; + while (restored) { + restored = false; + for (const [slot, info] of slots.entries()) { + if (!info.preserveWork || best[slot] === current[slot]) continue; + const candidate = [...best]; + const destination = candidate.indexOf(current[slot]); + if (destination >= 0) candidate[destination] = candidate[slot]; + candidate[slot] = current[slot]; + if (!valid(candidate)) continue; + const value = evaluate(candidate); + if (value.score >= metric.score && value.workRetained > metric.workRetained) { + best = candidate; metric = value; restored = true; + } + if (checks % 32 === 0) await options.onProgress?.(checks); + } + } + } await options.onProgress?.(checks); return encode(best); } diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index b084e5d..a6acc3b 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -1169,6 +1169,7 @@ +

단, 활성 노란 홀로워크 노드를 증폭 중인 카드는 이론상 고점을 높이는 데 필요한 경우에만 이동합니다. 점수가 같으면 발동률 증가보다 해당 카드의 기존 배치를 우선 유지합니다.

{#if connectionOptimizationProgress}
{connectionOptimizationProgress.stage}{#if connectionOptimizationProgress.total > 0}{connectionOptimizationProgress.completed.toLocaleString('ko-KR')} / {connectionOptimizationProgress.total.toLocaleString('ko-KR')}{/if}