feat: compare top five cooldown combinations in analysis
Publish GitHub Pages output / publish (push) Successful in 42s

This commit is contained in:
2026-09-25 23:09:49 +09:00
parent 29e70c565d
commit 7c7f0d40c0
5 changed files with 133 additions and 43 deletions
+22 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts';
import { optimizeExactOrderAndCooldowns, rankExactCooldownCombinations } from './exact-optimizer.ts';
import { passiveScoreSupportByCard } from './passive-support.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
@@ -21,6 +21,27 @@ const chart: ExactSongChart = {
specialStartSeconds: [1, 2, 3, 4, 5]
};
test('빈도 조합별 최적 순서를 최고점 순으로 최대 5개 반환한다', () => {
const flexible = cards.map((card, index) => ({ ...card, allowedCooldownReductions: index < 3 ? [0, 4] as const : [0] as const }));
const exhaustive = [0, 4].flatMap((a) => [0, 4].flatMap((b) => [0, 4].map((c) =>
optimizeExactOrderAndCooldowns(cards.map((card, index) => ({
...card, allowedCooldownReductions: [[a, b, c, 0, 0][index] as 0 | 4]
})), chart, { perfectNoteScore: 100 })
))).sort((left, right) => right.theoreticalMaxScore - left.theoreticalMaxScore ||
right.expectedScore - left.expectedScore ||
Object.values(left.cooldownReductions).reduce<number>((a, b) => a + b, 0) - Object.values(right.cooldownReductions).reduce<number>((a, b) => a + b, 0));
const ranked = rankExactCooldownCombinations(flexible, chart, { perfectNoteScore: 100 });
assert.equal(ranked.length, 5);
assert.equal(new Set(ranked.map((result) => JSON.stringify(result.cooldownReductions))).size, 5);
ranked.forEach((result, index) => {
assert.equal(result.theoreticalMaxScore, exhaustive[index].theoreticalMaxScore);
assert.deepEqual(result.cooldownReductions, exhaustive[index].cooldownReductions);
assert.deepEqual(result.order, exhaustive[index].order);
assert.equal(result.evaluatedCandidates, 120 * 8);
});
assert.equal(rankExactCooldownCombinations(cards, chart, { perfectNoteScore: 100 }).length, 1);
});
test('정확 분석 결과에 최적 조합의 액티브와 스페셜 지속 구간을 포함한다', () => {
const result = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100 });
+32 -29
View File
@@ -226,6 +226,16 @@ export function optimizeExactOrderAndCooldowns(
chart: ExactSongChart,
options: ExactOptimizeOptions
): ExactOptimizationResult {
return rankExactCooldownCombinations(cards, chart, options, 1)[0];
}
/** Keep the best special order for each distinct per-card cooldown assignment. */
export function rankExactCooldownCombinations(
cards: readonly OptimizerCard[],
chart: ExactSongChart,
options: ExactOptimizeOptions,
limit = 5
): ExactOptimizationResult[] {
if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) {
throw new Error('서로 다른 카드 5장이 필요합니다.');
}
@@ -234,46 +244,39 @@ export function optimizeExactOrderAndCooldowns(
const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1);
const totalCandidates = orderCount * reductionAssignments.length;
let evaluatedCandidates = 0;
let bestTheoreticalMaxScore = Number.NEGATIVE_INFINITY;
let bestExpectedScore = Number.NEGATIVE_INFINITY;
let bestOrder: OptimizerCard[] | null = null;
let bestReductions: Record<string, CooldownReduction> | null = null;
let bestReductionTotal = Number.POSITIVE_INFINITY;
type Candidate = {
order: OptimizerCard[];
reductions: Record<string, CooldownReduction>;
theoreticalMaxScore: number;
expectedScore: number;
reductionTotal: number;
};
const bestByAssignment = new Map<Record<string, CooldownReduction>, Candidate>();
const compare = (left: Candidate, right: Candidate): number =>
(options.objective === 'expected'
? right.expectedScore - left.expectedScore || right.theoreticalMaxScore - left.theoreticalMaxScore
: right.theoreticalMaxScore - left.theoreticalMaxScore || right.expectedScore - left.expectedScore) ||
left.reductionTotal - right.reductionTotal;
for (const order of permutations(cards)) {
const profiles = makeProfiles(order, chart);
for (const reductions of reductionAssignments) {
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
const reductionTotal = Object.values(reductions).reduce<number>((sum, value) => sum + value, 0);
const expectedFirst = options.objective === 'expected';
const isBetterPrimary = expectedFirst
? score.expectedScore > bestExpectedScore
: score.theoreticalMaxScore > bestTheoreticalMaxScore;
const isBetterSecondary = expectedFirst
? score.expectedScore === bestExpectedScore && score.theoreticalMaxScore > bestTheoreticalMaxScore
: score.theoreticalMaxScore === bestTheoreticalMaxScore && score.expectedScore > bestExpectedScore;
const isCheaperTie = score.theoreticalMaxScore === bestTheoreticalMaxScore &&
score.expectedScore === bestExpectedScore && reductionTotal < bestReductionTotal;
if (isBetterPrimary || isBetterSecondary || isCheaperTie) {
bestTheoreticalMaxScore = score.theoreticalMaxScore;
bestExpectedScore = score.expectedScore;
bestOrder = order;
bestReductions = reductions;
bestReductionTotal = reductionTotal;
}
const candidate = { ...score, order, reductions, reductionTotal };
const previous = bestByAssignment.get(reductions);
if (!previous || compare(candidate, previous) < 0) bestByAssignment.set(reductions, candidate);
evaluatedCandidates += 1;
}
options.onProgress?.({ evaluatedCandidates, totalCandidates });
}
if (!bestOrder || !bestReductions) throw new Error('분석 결과를 만들지 못했습니다.');
const windows = buildExactSkillWindows(bestOrder, bestReductions, chart);
return {
...calculateExactScore(bestOrder, bestReductions, chart, options),
order: bestOrder.map((card) => card.id),
return [...bestByAssignment.values()].sort(compare).slice(0, Math.max(1, Math.floor(limit))).map((candidate) => ({
...calculateExactScore(candidate.order, candidate.reductions, chart, options),
order: candidate.order.map((card) => card.id),
formationOrder: cards.map((card) => card.id),
cooldownReductions: { ...bestReductions },
...windows,
cooldownReductions: { ...candidate.reductions },
...buildExactSkillWindows(candidate.order, candidate.reductions, chart),
evaluatedCandidates
};
}));
}
+13 -11
View File
@@ -1,6 +1,7 @@
/// <reference lib="webworker" />
import { countOptimizationCandidates, estimateScoreAchievementProbabilities, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
import { rankExactCooldownCombinations } from '../engine/exact-optimizer.ts';
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard, ScoreAchievementProbabilities } from '../engine/index.ts';
type OptimizeRequest = {
@@ -10,6 +11,7 @@ type OptimizeRequest = {
perfectNoteScore: number;
directSongScoreBonusPercent?: number;
includeAchievementProbabilities?: boolean;
cooldownResultLimit?: number;
};
type CombinationCandidate = {
@@ -77,19 +79,19 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
directSongScoreBonusPercent: event.data.directSongScoreBonusPercent,
onProgress: (progress) => self.postMessage({ type: 'progress', progress })
};
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
let achievementProbabilities: ScoreAchievementProbabilities | undefined;
if (event.data.includeAchievementProbabilities) {
self.postMessage({ type: 'probability-start' });
const orderedCards = cardsForResult(event.data.cards, result);
achievementProbabilities = estimateScoreAchievementProbabilities(
orderedCards,
const request = event.data;
const results = rankExactCooldownCombinations(request.cards, request.chart, options, request.cooldownResultLimit === 5 ? 5 : 1);
if (request.includeAchievementProbabilities) self.postMessage({ type: 'probability-start' });
const cooldownRankings = results.map((result) => {
const achievementProbabilities = request.includeAchievementProbabilities ? estimateScoreAchievementProbabilities(
cardsForResult(request.cards, result),
result.cooldownReductions,
event.data.chart,
request.chart,
{ ...options, sampleCount: 25_000 }
);
}
self.postMessage({ type: 'result', result, achievementProbabilities });
) : undefined;
return { result, achievementProbabilities };
});
self.postMessage({ type: 'result', ...cooldownRankings[0], cooldownRankings });
return;
}