fix: preserve Holo-work connections without score gains

This commit is contained in:
2026-09-20 19:08:34 +09:00
parent 207a733d66
commit f18bb63f1b
4 changed files with 105 additions and 7 deletions
+7 -1
View File
@@ -1,7 +1,13 @@
---
updatedAt: 2026-09-18T03:36:33.635Z
updatedAt: 2026-09-20T04:48:31Z
---
## 2026년 9월 20일
### 홀로워크용 연결 카드 유지
`IMPROVED` 연결 카드 최적화에서 활성 노란 홀로워크 노드를 증폭 중인 카드는 이론상 고점을 높이는 데 필요한 경우에만 이동합니다. 점수가 같으면 발동률 증가보다 기존 배치를 우선하며, 다른 카드의 점수 개선 때문에 불필요하게 이동한 홀로워크 카드도 복원합니다.
## 2026년 9월 18일
### 홀로워크 조합 추천과 목록 개선
@@ -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);
@@ -5,7 +5,7 @@ type Placements = Record<string, Record<string, string>>;
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<typeof evaluate>;
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);
}
+1
View File
@@ -1169,6 +1169,7 @@
<button type="button" class="primary" disabled={resultStale || running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>
</div>
</div>
<p>단, 활성 노란 홀로워크 노드를 증폭 중인 카드는 이론상 고점을 높이는 데 필요한 경우에만 이동합니다. 점수가 같으면 발동률 증가보다 해당 카드의 기존 배치를 우선 유지합니다.</p>
{#if connectionOptimizationProgress}
<div class="connection-optimizer-progress" aria-live="polite">
<div><strong>{connectionOptimizationProgress.stage}</strong>{#if connectionOptimizationProgress.total > 0}<span>{connectionOptimizationProgress.completed.toLocaleString('ko-KR')} / {connectionOptimizationProgress.total.toLocaleString('ko-KR')}</span>{/if}</div>