feat: 조합 탐색 정확도 향상
Publish GitHub Pages output / publish (push) Successful in 30s

고정 멤버와 탐색 목적을 유지하며 제한적인 두 카드 교체 후보를 정밀 평가합니다.
기존 평가 예산 안에서 이미 평가한 편성을 제외하고 후보를 재선택합니다.
This commit is contained in:
dal4segno committed 2026-10-13 14:34:31 +09:00
1 parent da40ec4238
commit 1e893e62c9
7 files changed
+330 -8

No files matched your search

@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { generateTwoCardReplacementCandidates, refinementCandidatePriority, refinementCombinationKey, selectRefinementCandidates } from './combination-refinement.ts';
import type { OptimizerCard } from './types.ts';
function optimizer(id: string, percent = 0, probability = 100): OptimizerCard {
return { id, name: id, active: { cooldownSeconds: 10, durationSeconds: 10, scoreUpPercent: percent, probabilityPercent: probability }, special: { durationSeconds: 0, scoreUpPercent: 0 } };
}
test('refinement priority distinguishes maximum from expected and retains event/song multipliers', () => {
const risky = { cards: [optimizer('risky', 150, 20)], overallPower: 1000 };
const steady = { cards: [optimizer('steady', 100)], overallPower: 1000 };
assert.ok(refinementCandidatePriority(risky) > refinementCandidatePriority(steady));
assert.ok(refinementCandidatePriority({ ...risky, optimizationObjective: 'expected' }) <
refinementCandidatePriority({ ...steady, optimizationObjective: 'expected' }));
const plain = { cards: [optimizer('plain')], overallPower: 1000 };
assert.ok(refinementCandidatePriority({ ...plain, overallPower: 900, rankingMultiplier: 2, optimizationObjective: 'expected' }) > refinementCandidatePriority(plain));
assert.ok(refinementCandidatePriority({ ...plain, overallPower: 900, directSongScoreBonusPercent: 10, finalScoreBonusPercent: 10 }) > refinementCandidatePriority(plain));
});
type Card = { id: string; characterId: string; medium: boolean };
const cards: Card[] = Array.from({ length: 7 }, (_, index) => ({ id: String(index), characterId: String(index), medium: index >= 5 }));
const accepts = (selected: readonly Card[]) => selected.filter((card) => card.medium).length <= 1;
const prepare = (selected: Card[]) => ({ cards: selected.map((card) => optimizer(card.id)), overallPower: 100 + selected.reduce((sum, card) => sum + Number(card.id), 0) });
function choose<T>(items: readonly T[], count: number): T[][] {
if (!count) return [[]];
return items.flatMap((item, index) => choose(items.slice(index + 1), count - 1).map((rest) => [item, ...rest]));
}
test('bounded two-card search matches the exhaustive tiny neighborhood and preserves fixed members', () => {
const source = cards.slice(0, 5), fixed = new Set(['0', '1']), expected = new Set<string>();
for (const removed of choose([2, 3, 4], 2)) {
const kept = source.filter((_, index) => !removed.includes(index));
for (const pair of choose(cards, 2)) {
const result = [...kept, ...pair];
if (new Set(result.map((card) => card.characterId)).size === 5 && accepts(result)) expected.add(refinementCombinationKey(result));
}
}
const actual = generateTwoCardReplacementCandidates([source], cards, fixed, prepare, refinementCandidatePriority, accepts);
assert.deepEqual(actual.map((candidate) => refinementCombinationKey(candidate.cards)).sort(), [...expected].sort());
assert.ok(actual.every((candidate) => ['0', '1'].every((id) => candidate.cards.some((card) => card.id === id))));
});
test('replacement search uses only the first three sources and returns no changes with four fixed members', () => {
const pool = Array.from({ length: 12 }, (_, index) => ({ id: String(index), characterId: String(index), medium: false }));
const sources = [pool.slice(0, 5), [pool[0], pool[1], pool[2], pool[3], pool[5]], [pool[0], pool[1], pool[2], pool[3], pool[6]], pool.slice(7, 12)];
const found = generateTwoCardReplacementCandidates(sources, pool, new Set<string>(), prepare, refinementCandidatePriority, () => true);
assert.ok(found.every((candidate) => sources.slice(0, 3).some((source) => source.filter((card) => candidate.cards.some((item) => item.id === card.id)).length >= 3)));
assert.deepEqual(generateTwoCardReplacementCandidates([sources[0]], pool, new Set(['0', '1', '2', '3']), prepare, refinementCandidatePriority, () => true), []);
});
test('mixed refinement removes already evaluated teams and permutations before applying the shared budget', () => {
const a = { cards: [optimizer('a'), optimizer('b')], overallPower: 100 };
const duplicate = { ...a, cards: [...a.cards].reverse() };
const b = { cards: [optimizer('c'), optimizer('d')], overallPower: 200 };
const c = { cards: [optimizer('e'), optimizer('f')], overallPower: 300 };
assert.deepEqual(selectRefinementCandidates([a, duplicate, b, c], new Set([refinementCombinationKey(c.cards)]), 1), [b]);
assert.deepEqual(selectRefinementCandidates([a], new Set<string>(), 0), []);
assert.equal(selectRefinementCandidates([a, duplicate, b], new Set<string>(), 10).length, 2);
});