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일
|
## 2026년 9월 19일
|
||||||
|
|
||||||
### Grave of Halo 악곡 추가
|
### Grave of Halo 악곡 추가
|
||||||
|
|||||||
@@ -35,6 +35,65 @@ function fixture(sisters = false) {
|
|||||||
return { source, boards, cards, evaluate, singers };
|
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]) {
|
for (const sisters of [false, true]) {
|
||||||
test(`${sisters ? '후와모코' : '유닛'} 보너스 상한을 넘기는 노란 카드를 다른 보드로 이동한다`, async () => {
|
test(`${sisters ? '후와모코' : '유닛'} 보너스 상한을 넘기는 노란 카드를 다른 보드로 이동한다`, async () => {
|
||||||
const { source, boards, cards, evaluate, singers } = fixture(sisters);
|
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 };
|
type Metric = { score: number; activationRateUp: number };
|
||||||
|
|
||||||
/** Bounded local correction for song-bonus saturation, not a global search.
|
/** 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.
|
* Every comparison scores the whole board, including caps and rounding.
|
||||||
*/
|
*/
|
||||||
export async function refineBoardConnectionSongBonus(
|
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'
|
&& 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));
|
&& (!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))),
|
allowed: cards.map((card) => required.every((position) => covers(card, position))),
|
||||||
focus: cards.some((card) => yellow.some((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));
|
const metric = options.evaluate(encode(assignment));
|
||||||
if (!Number.isFinite(metric.score) || !Number.isFinite(metric.activationRateUp)) throw new Error('연결 카드 배치 평가값이 유효하지 않습니다.');
|
if (!Number.isFinite(metric.score) || !Number.isFinite(metric.activationRateUp)) throw new Error('연결 카드 배치 평가값이 유효하지 않습니다.');
|
||||||
checks += 1;
|
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>;
|
type Ranked = ReturnType<typeof evaluate>;
|
||||||
const better = (a: Ranked, b: Ranked) => a.score > b.score || (a.score === b.score
|
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);
|
let best = decode(initial);
|
||||||
if (!valid(best)) throw new Error('연결 카드 초기 배치가 필수 범위 조건을 충족하지 않습니다.');
|
if (!valid(best)) throw new Error('연결 카드 초기 배치가 필수 범위 조건을 충족하지 않습니다.');
|
||||||
let metric = evaluate(best);
|
let metric = evaluate(best);
|
||||||
if (current && valid(current)) { const value = evaluate(current); if (better(value, metric)) { best = current; metric = value; } }
|
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] : []);
|
const focus = slots.flatMap((slot, index) => slot.focus || slot.preserveWork ? [index] : []);
|
||||||
// At most three passes: O(3 * yellow sockets * (all sockets + owned cards)).
|
// At most three search passes over song and existing work sockets.
|
||||||
for (let pass = 0; pass < 3 && focus.length; pass += 1) {
|
for (let pass = 0; pass < 3 && focus.length; pass += 1) {
|
||||||
let next = best;
|
let next = best;
|
||||||
let nextMetric = metric;
|
let nextMetric = metric;
|
||||||
@@ -102,6 +112,28 @@ export async function refineBoardConnectionSongBonus(
|
|||||||
if (next === best) break;
|
if (next === best) break;
|
||||||
best = next; metric = nextMetric;
|
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);
|
await options.onProgress?.(checks);
|
||||||
return encode(best);
|
return encode(best);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1169,6 +1169,7 @@
|
|||||||
<button type="button" class="primary" disabled={resultStale || running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>
|
<button type="button" class="primary" disabled={resultStale || running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p>단, 활성 노란 홀로워크 노드를 증폭 중인 카드는 이론상 고점을 높이는 데 필요한 경우에만 이동합니다. 점수가 같으면 발동률 증가보다 해당 카드의 기존 배치를 우선 유지합니다.</p>
|
||||||
{#if connectionOptimizationProgress}
|
{#if connectionOptimizationProgress}
|
||||||
<div class="connection-optimizer-progress" aria-live="polite">
|
<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>
|
<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