diff --git a/UPDATES.md b/UPDATES.md
index 851a658..3388c95 100644
--- a/UPDATES.md
+++ b/UPDATES.md
@@ -1,9 +1,13 @@
---
-updatedAt: 2026-09-10T10:52:51+09:00
+updatedAt: 2026-09-10T14:39:25+09:00
---
## 2026년 9월 10일
+### 시간별 최고점 유지 확률
+
+`NEW` 배치 분석의 시간별 점수 그래프에 커서를 올리면 해당 시각까지 최고점을 유지할 확률을 함께 표시합니다. 모든 노트 PERFECT와 액티브 발동 확률을 기준으로 계산하며, 액티브 미발동 토글 시 현재 설정의 최고점 기준으로 갱신됩니다.
+
### 액티브 점수 영향력 표시
`NEW` 배치 분석에서 현재 설정의 최고점에 미치는 영향이 큰 액티브를 진한 색과 굵은 테두리로 강조합니다. 구간에 마우스를 올리면 토글 시 점수 변화를 확인할 수 있으며, 미발동 구간은 복원 시 영향력을 표시합니다.
diff --git a/src/lib/components/ScoreTimelineChart.svelte b/src/lib/components/ScoreTimelineChart.svelte
index 2497414..c1fbeda 100644
--- a/src/lib/components/ScoreTimelineChart.svelte
+++ b/src/lib/components/ScoreTimelineChart.svelte
@@ -2,6 +2,7 @@
import type { NoteScorePoint } from '$lib/engine/index.ts';
export let notes: readonly NoteScorePoint[];
+ export let maximumScoreProbabilities: readonly number[] = [];
export let durationSeconds: number;
export let minimumWidth = 720;
export let timeTicks: readonly number[] = [];
@@ -10,6 +11,7 @@
timeSeconds: number;
averageScore: number;
maximumScore: number;
+ maximumProbability?: number;
};
const HEIGHT = 220;
@@ -25,7 +27,7 @@
$: viewWidth = Math.max(320, chartWidth);
$: plotWidth = viewWidth - LEFT - RIGHT;
- $: points = buildPoints(notes);
+ $: points = buildPoints(notes, maximumScoreProbabilities);
$: finalMaximumScore = Math.max(1, points.at(-1)?.maximumScore ?? 0);
$: chartMaximumScore = Math.max(500_000, Math.ceil(finalMaximumScore / 500_000) * 500_000);
$: averagePath = linePath(points, 'averageScore', durationSeconds, chartMaximumScore, viewWidth);
@@ -41,14 +43,14 @@
tooltipPercent = 0;
}
- function buildPoints(source: readonly NoteScorePoint[]): ScorePoint[] {
+ function buildPoints(source: readonly NoteScorePoint[], probabilities: readonly number[]): ScorePoint[] {
let averageScore = 0;
let maximumScore = 0;
- const result: ScorePoint[] = [{ timeSeconds: 0, averageScore: 0, maximumScore: 0 }];
- for (const note of source) {
+ const result: ScorePoint[] = [{ timeSeconds: 0, averageScore: 0, maximumScore: 0, maximumProbability: 1 }];
+ for (const [index, note] of source.entries()) {
averageScore += note.expectedScore;
maximumScore += note.theoreticalMaxScore;
- result.push({ timeSeconds: note.timeSeconds, averageScore, maximumScore });
+ result.push({ timeSeconds: note.timeSeconds, averageScore, maximumScore, maximumProbability: probabilities[index] });
}
return result;
}
@@ -114,6 +116,12 @@
return Math.round(score).toLocaleString('ko-KR');
}
+ function formatProbability(probability: number | undefined): string {
+ if (probability === undefined) return '—';
+ if (probability > 0 && probability < 0.000001) return '<0.0001%';
+ return `${(probability * 100).toLocaleString('ko-KR', { maximumFractionDigits: probability < 0.0001 ? 4 : 2 })}%`;
+ }
+
function formatAxisScore(score: number): string {
return score === 0 ? '0' : `${Math.round(score / 10_000).toLocaleString('ko-KR')}만`;
}
@@ -147,7 +155,7 @@
aria-valuemin="0"
aria-valuemax={Math.ceil(durationSeconds)}
aria-valuenow={Math.round(hoverTime ?? 0)}
- aria-valuetext={hoveredPoint && hoverTime !== null ? `${formatTime(hoverTime)}, 평균 점수 ${formatScore(hoveredPoint.averageScore)}, 최고 점수 ${formatScore(hoveredPoint.maximumScore)}` : '방향키로 시각을 선택하세요'}
+ aria-valuetext={hoveredPoint && hoverTime !== null ? `${formatTime(hoverTime)}, 평균 점수 ${formatScore(hoveredPoint.averageScore)}, 최고 점수 ${formatScore(hoveredPoint.maximumScore)}, 이 시간까지 최고점 유지 확률 ${formatProbability(hoveredPoint.maximumProbability)}` : '방향키로 시각을 선택하세요'}
tabindex="0"
on:pointermove={onPointerMove}
on:pointerleave={() => hoverTime = null}
@@ -176,10 +184,11 @@
{#if hoveredPoint && hoverTime !== null}
-
68} class="tooltip" style={`left: ${tooltipPercent}%;`} role="status">
+
50} class="tooltip" style={`left: ${tooltipPercent}%;`} role="status">
{formatTime(hoverTime)}
평균 점수 {formatScore(hoveredPoint.averageScore)}
최고 점수 {formatScore(hoveredPoint.maximumScore)}
+ 이 시간까지 최고점 유지 확률 {formatProbability(hoveredPoint.maximumProbability)}
{/if}
diff --git a/src/lib/engine/score-probability.test.ts b/src/lib/engine/score-probability.test.ts
index 24d6b80..2dcc56a 100644
--- a/src/lib/engine/score-probability.test.ts
+++ b/src/lib/engine/score-probability.test.ts
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { estimateScoreAchievementProbabilities } from './index.ts';
+import { calculateMaximumScoreProbabilityTimeline } from './score-probability.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
function card(
@@ -104,3 +105,32 @@ test('확정 발동이면 모든 달성 확률이 100%다', () => {
samples: 1_000
});
});
+
+test('시간별 최고점 확률은 첫 영향 노트에서 감소하고 같은 발동을 중복 계산하지 않는다', () => {
+ const cards = [card('main', 100, 50, 0.9), card('weak', 20, 10)];
+ const source = chart([0.5, 1.1, 1.5, 1.95, 2.1, 2.1]);
+ const options = { perfectNoteScore: 100, sampleCount: 1 };
+ const probabilities = calculateMaximumScoreProbabilityTimeline(cards, {}, source, options);
+ // The weak skill alone raises the maximum at 1.95 seconds.
+ assert.deepEqual(probabilities, [1, 0.5, 0.5, 0.05, 0.025, 0.025]);
+ assert.equal(probabilities.at(-1), estimateScoreAchievementProbabilities(cards, {}, source, options).maximum);
+ assert.deepEqual(calculateMaximumScoreProbabilityTimeline(cards, {}, chart([]), options), []);
+});
+
+test('시간별 확률은 겹친 동률 액티브와 이후 필수 발동 사이의 의존성을 반영한다', () => {
+ const cards = [card('long', 100, 50, 0.9), card('short', 100, 50, 0.3)];
+ assert.deepEqual(calculateMaximumScoreProbabilityTimeline(cards, {}, chart([1.1, 1.5, 2.1]), {
+ perfectNoteScore: 100
+ }), [0.75, 0.5, 0.375]);
+});
+
+test('미발동 토글 시 현재 최고점 기준으로 확률을 계산하고 반올림으로 점수가 같으면 실패를 허용한다', () => {
+ const cards = [card('strong', 100, 50), card('weak', 20, 10)];
+ assert.deepEqual(calculateMaximumScoreProbabilityTimeline(cards, {}, chart([1.1, 2.1]), {
+ perfectNoteScore: 100,
+ excludedActiveWindows: [{ cardId: 'strong', startSeconds: 1 }]
+ }), [0.1, 0.05]);
+ const options = { perfectNoteScore: 0.1, sampleCount: 1 };
+ assert.deepEqual(calculateMaximumScoreProbabilityTimeline(cards, {}, chart([1.1]), options), [1]);
+ assert.equal(estimateScoreAchievementProbabilities(cards, {}, chart([1.1]), options).maximum, 1);
+});
diff --git a/src/lib/engine/score-probability.ts b/src/lib/engine/score-probability.ts
index 444a1d3..61a54f8 100644
--- a/src/lib/engine/score-probability.ts
+++ b/src/lib/engine/score-probability.ts
@@ -62,7 +62,8 @@ function prepareModel(
cards: readonly OptimizerCard[],
reductions: Readonly>,
chart: ExactSongChart,
- options: ExactScoreOptions
+ options: ExactScoreOptions,
+ onNote?: (probability: number) => void
): PreparedProbabilityModel {
const attempts: Attempt[] = [];
const attemptIndexes = new Map();
@@ -70,6 +71,8 @@ function prepareModel(
const grouped = new Map();
const clauses: number[][] = [];
let maximumScore = 0;
+ let prefixProbability = 1;
+ let prefixClauses: number[][] = [];
scoreNotes(chart).forEach((note, noteIndex) => {
const candidates: NoteCandidate[] = [];
@@ -80,6 +83,8 @@ function prepareModel(
const attemptNumber = Math.floor((note.timeSeconds + 1e-9) / interval);
if (attemptNumber <= 0) return;
const activationTime = attemptNumber * interval;
+ if (options.excludedActiveWindows?.some((window) =>
+ window.cardId === card.id && Math.abs(window.startSeconds - activationTime) < 1e-9)) return;
if (note.timeSeconds >= activationTime + card.active.durationSeconds) return;
const key = `${card.id}:${attemptNumber}`;
let attemptIndex = attemptIndexes.get(key);
@@ -110,11 +115,18 @@ function prepareModel(
];
maximumScore += scores[0];
- if (candidates.length > 0) {
- clauses.push(candidates
+ // No activation is required when rounding gives the baseline the same score.
+ if (scores[0] > scores[scores.length - 1]) {
+ const clause = candidates
.filter((_, index) => scores[index] === scores[0])
- .map((candidate) => candidate.attemptIndex));
+ .map((candidate) => candidate.attemptIndex);
+ clauses.push(clause);
+ if (onNote && !prefixClauses.some((existing) => existing.every((variable) => clause.includes(variable)))) {
+ prefixClauses = simplifyClauses([...prefixClauses, clause]);
+ prefixProbability = exactClauseProbability(prefixClauses, attempts);
+ }
}
+ onNote?.(prefixProbability);
const signature = candidates.map((candidate) => `${candidate.attemptIndex}:${candidate.boost.toPrecision(15)}`).join('|');
const existing = grouped.get(signature);
@@ -130,6 +142,18 @@ function prepareModel(
};
}
+/** Exact cumulative maximum-score probability after each chart note, in note order. */
+export function calculateMaximumScoreProbabilityTimeline(
+ cards: readonly OptimizerCard[],
+ reductions: Readonly>,
+ chart: ExactSongChart,
+ options: ExactScoreOptions
+): number[] {
+ const probabilities: number[] = [];
+ prepareModel(cards, reductions, chart, options, (probability) => probabilities.push(probability));
+ return probabilities;
+}
+
function simplifyClauses(clauses: readonly (readonly number[])[]): number[][] {
const unique = new Map();
for (const clause of clauses) {
diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte
index c9724a1..cbf25d4 100644
--- a/src/routes/analysis/+page.svelte
+++ b/src/routes/analysis/+page.svelte
@@ -18,6 +18,7 @@
import { releasedSongCharts } from '$lib/data/song-release.ts';
import PresetManager from '$lib/components/PresetManager.svelte';
import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte';
+ import { calculateMaximumScoreProbabilityTimeline } from '$lib/engine/score-probability.ts';
import SongPicker from '$lib/components/SongPicker.svelte';
import { buildExactSkillWindows, calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
import type { ExactScoreOptions, OptimizerCard, CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
@@ -134,6 +135,13 @@
excludedActiveWindows = [];
}
$: displayedResult = simulatedResult ?? result;
+ $: maximumScoreProbabilities = result && simulationContext
+ ? calculateMaximumScoreProbabilityTimeline(
+ result.order.map((id) => simulationContext!.cards.find((card) => card.id === id)!),
+ result.cooldownReductions, simulationContext.chart,
+ { ...simulationContext.options, excludedActiveWindows }
+ )
+ : [];
$: activeImpacts = result && simulationContext && mode === 'analysis'
? calculateActiveImpacts(
result.order.map((id) => simulationContext!.cards.find((card) => card.id === id)!),
@@ -1215,7 +1223,7 @@
{/if}