Compare commits
2
Commits
b29e8114f6
...
5d0361de26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d0361de26 | ||
|
|
f18bb63f1b |
+7
-1
@@ -1,7 +1,13 @@
|
||||
---
|
||||
updatedAt: 2026-09-19T11:05:00.000Z
|
||||
updatedAt: 2026-09-20T04:48:31Z
|
||||
---
|
||||
|
||||
## 2026년 9월 20일
|
||||
|
||||
### 홀로워크용 연결 카드 유지
|
||||
|
||||
`IMPROVED` 연결 카드 최적화에서 활성 노란 홀로워크 노드를 증폭 중인 카드는 이론상 고점을 높이는 데 필요한 경우에만 이동합니다. 점수가 같으면 발동률 증가보다 기존 배치를 우선하며, 다른 카드의 점수 개선 때문에 불필요하게 이동한 홀로워크 카드도 복원합니다.
|
||||
|
||||
## 2026년 9월 19일
|
||||
|
||||
### Grave of Halo 악곡 추가
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user