fix: reconsider center card upgrades after placement fallback
Publish GitHub Pages output / publish (push) Successful in 38s

This commit is contained in:
2026-09-21 13:17:23 +09:00
parent 0bdade44e3
commit ae7f526160
4 changed files with 65 additions and 9 deletions
@@ -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<string, Record<string, string>>) => ({
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<string, Record<string, string>>) => ({
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));
});
@@ -4,8 +4,9 @@ import type { BoardConnectionOptimizerSource, OptimizableConnectionCard } from '
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/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;
+2 -2
View File
@@ -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<void>((resolve) => requestAnimationFrame(() => resolve()));
}
});
@@ -1168,7 +1168,7 @@
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
<div class="connection-optimizer-heading">
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 편성 멤버의 파란 영역 연결 카드는 반드시 인근의 큰 발동률 노드를 포함합니다. 편성 밖 멤버의 보드도 현재 편성의 점수에 기여하는 효과는 최적화에 반영합니다. 빠른 배정 후 노란 보너스 상한에 따른 이동·교환을 최대 3차례 보정하며, 전역 최적해를 보장하지 않습니다. 이 조건 안에서 최고점을 최우선으로 비교하고 최고점이 같으면 액티브 발동률 UP 합계를 비교합니다. 두 값이 모두 같으면 기존 연결 카드 배치를 우선 유지합니다. 조건을 충족할 수 없거나 최종 검증에서 최고점이 낮아지면 기존 배치를 유지합니다.</p></div>
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 편성 멤버의 파란 영역 연결 카드는 반드시 인근의 큰 발동률 노드를 포함합니다. 편성 밖 멤버의 보드도 현재 편성의 점수에 기여하는 효과는 최적화에 반영합니다. 빠른 배정 후 중앙 카드 교체·교환과 노란 보너스 상한에 따른 이동·교환을 전체 배치 점수로 최대 3차례 보정하며, 전역 최적해를 보장하지 않습니다. 초기 추천보다 기존 배치가 좋으면 기존 배치에서 개선을 탐색합니다. 이 조건 안에서 최고점을 최우선으로 비교하고 최고점이 같으면 액티브 발동률 UP 합계를 비교합니다. 두 값이 모두 같으면 기존 연결 카드 배치를 우선 유지합니다. 조건을 충족할 수 없거나 최종 검증에서 최고점이 낮아지면 기존 배치를 유지합니다.</p></div>
<div class="connection-optimizer-actions">
{#if connectionOptimizationSnapshot}<button type="button" disabled={running || connectionOptimizationRunning} on:click={undoConnectionOptimization}>직전 배치 복원</button>{/if}
<button type="button" class="primary" disabled={resultStale || running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>