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;