From 7c7f0d40c04b0f2ce50786303659eda020c2ef3e Mon Sep 17 00:00:00 2001 From: dal4segno Date: Fri, 25 Sep 2026 23:09:49 +0900 Subject: [PATCH] feat: compare top five cooldown combinations in analysis --- UPDATES.md | 6 ++- src/lib/engine/exact-optimizer.test.ts | 23 ++++++++- src/lib/engine/exact-optimizer.ts | 61 +++++++++++----------- src/lib/workers/score-optimizer.worker.ts | 24 +++++---- src/routes/analysis/+page.svelte | 62 ++++++++++++++++++++++- 5 files changed, 133 insertions(+), 43 deletions(-) diff --git a/UPDATES.md b/UPDATES.md index 37a1ae0..9096daf 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,9 +1,13 @@ --- -updatedAt: 2026-09-25T11:16:00Z +updatedAt: 2026-09-25T14:08:52Z --- ## 2026년 9월 25일 +### 배치 분석 빈도 조합 비교 + +`IMPROVED` 서로 다른 빈도 감소 조합을 최고점 내림차순으로 최대 5개 표시합니다. 각 조합의 예상 점수와 고점 달성 확률을 비교하고, 원하는 결과를 선택해 상세 점수와 그래프를 확인할 수 있습니다. 점수·확률 열의 최댓값은 공동 최댓값을 포함해 굵게 표시합니다. + ### 64. 악곡과 이벤트 과제곡 선택 `DATA` 아냐 멜핏사의 솔로곡 64.를 추가했습니다. Easy·Normal·Hard·Expert의 정확한 채보와 곡별 점수 계산을 지원합니다. diff --git a/src/lib/engine/exact-optimizer.test.ts b/src/lib/engine/exact-optimizer.test.ts index 636dfd6..05df851 100644 --- a/src/lib/engine/exact-optimizer.test.ts +++ b/src/lib/engine/exact-optimizer.test.ts @@ -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((a, b) => a + b, 0) - Object.values(right.cooldownReductions).reduce((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 }); diff --git a/src/lib/engine/exact-optimizer.ts b/src/lib/engine/exact-optimizer.ts index bd8ff2c..14a9a11 100644 --- a/src/lib/engine/exact-optimizer.ts +++ b/src/lib/engine/exact-optimizer.ts @@ -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 | null = null; - let bestReductionTotal = Number.POSITIVE_INFINITY; + type Candidate = { + order: OptimizerCard[]; + reductions: Record; + theoreticalMaxScore: number; + expectedScore: number; + reductionTotal: number; + }; + const bestByAssignment = new Map, 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((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 - }; + })); } diff --git a/src/lib/workers/score-optimizer.worker.ts b/src/lib/workers/score-optimizer.worker.ts index d94de5d..ce8e185 100644 --- a/src/lib/workers/score-optimizer.worker.ts +++ b/src/lib/workers/score-optimizer.worker.ts @@ -1,6 +1,7 @@ /// 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 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; } diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index 4378742..49412cf 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -118,6 +118,23 @@ let running = false; let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 }; let result: ExactOptimizationResult | null = null; + let cooldownRankings: { result: ExactOptimizationResult; achievementProbabilities?: ScoreAchievementProbabilities }[] = []; + $: cooldownColumnMaxima = { + score: Math.max(...cooldownRankings.map(({ result }) => result.theoreticalMaxScore)), + expected: Math.max(...cooldownRankings.map(({ result }) => result.expectedScore)), + maximum: Math.max(...cooldownRankings.map(({ achievementProbabilities: p }) => p?.maximum ?? -Infinity)), + atLeast995Percent: Math.max(...cooldownRankings.map(({ achievementProbabilities: p }) => p?.atLeast995Percent ?? -Infinity)), + atLeast99Percent: Math.max(...cooldownRankings.map(({ achievementProbabilities: p }) => p?.atLeast99Percent ?? -Infinity)), + atLeast95Percent: Math.max(...cooldownRankings.map(({ achievementProbabilities: p }) => p?.atLeast95Percent ?? -Infinity)) + }; + + function selectCooldownRanking(index: number): void { + const ranking = cooldownRankings[index]; + if (!ranking || running) return; + result = ranking.result; + achievementProbabilities = ranking.achievementProbabilities; + clearConnectionOptimizationProposal(); + } type SimulationContext = { cards: OptimizerCard[]; chart: ExactSongChart; options: ExactScoreOptions }; let simulationContext: SimulationContext | null = null; let pendingSimulationContext: SimulationContext | null = null; @@ -393,6 +410,7 @@ if (event.data.type === 'progress') progress = event.data.progress; if (event.data.type === 'result') { result = event.data.result; + cooldownRankings = event.data.cooldownRankings ?? []; transferredResult = false; resultCapture = pendingCapture; resultInputKey = pendingInputKey; @@ -864,7 +882,8 @@ chart, perfectNoteScore: noteScore, directSongScoreBonusPercent: songBonus, - includeAchievementProbabilities: true + includeAchievementProbabilities: true, + cooldownResultLimit: mode === 'analysis' ? 5 : 1 }); } catch (error) { running = false; @@ -1142,6 +1161,31 @@ {#if mode === 'analysis' && resultCapture}

{resultCapture.title} · {difficultyLabel(resultCapture.settings.difficulty)} · 분석 당시 종합력 {resultCapture.overallPower.toLocaleString('ko-KR')}

{/if} {#if mode === 'rating' && result}

{selectedChart?.title} · {difficultyLabel(difficulty)} · 종합력 {overallPower.toLocaleString('ko-KR')} · 가창 보너스 10%

{/if} {#if result} + {#if mode === 'analysis' && cooldownRankings.length > 0} +
+

빈도 조합 상위 {cooldownRankings.length}개

+

빈도 감소 조합마다 최적의 스페셜 순서를 구해 최고점 내림차순으로 표시합니다. 확률은 각 조합의 최고점 기준이며, 결과를 선택하면 아래 상세 점수와 그래프에 반영됩니다.

+
+ + + + {#each cooldownRankings as ranking, index} + + + + + + + + + + + {/each} + +
순위카드별 빈도 감소최고점예상 점수100% 달성99.5% 이상99% 이상95% 이상
{#each ranking.result.formationOrder as cardId}
{resultCardName(cardId)} {ranking.result.cooldownReductions[cardId]}%
{/each}
{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}{ranking.result.expectedScore.toLocaleString('ko-KR')}{probabilityLabel(ranking.achievementProbabilities?.maximum)}{probabilityLabel(ranking.achievementProbabilities?.atLeast995Percent)}{scoreThreshold(ranking.result.theoreticalMaxScore, 0.995)}점 이상{probabilityLabel(ranking.achievementProbabilities?.atLeast99Percent)}{scoreThreshold(ranking.result.theoreticalMaxScore, 0.99)}점 이상{probabilityLabel(ranking.achievementProbabilities?.atLeast95Percent)}{scoreThreshold(ranking.result.theoreticalMaxScore, 0.95)}점 이상
+
+
+ {/if}

추천 스페셜 발동 순서

@@ -1333,6 +1377,22 @@