feat: show 99.5 percent score achievement probability
Publish GitHub Pages output / publish (push) Successful in 35s

This commit is contained in:
2026-09-25 08:22:37 +09:00
parent 8edb732dd9
commit 167c933032
8 changed files with 72 additions and 11 deletions
+7 -1
View File
@@ -1,7 +1,13 @@
---
updatedAt: 2026-09-21T11:11:21Z
updatedAt: 2026-09-24T23:21:41Z
---
## 2026년 9월 25일
### 배치 분석 고점 달성 확률 추가
`IMPROVED` 배치 분석 결과와 저장 기록 상세에 최고점의 99.5% 이상을 얻을 확률과 기준 점수를 추가했습니다. 기존 99%·95% 확률과 함께 25,000회 반복 계산합니다.
## 2026년 9월 23일
### Lethal Dose Paranoid 악곡 추가
+24 -4
View File
@@ -3,12 +3,12 @@
import AnalysisResultView from './AnalysisResultView.svelte';
import { saveAnalysis, listAnalyses, readGrowth, deleteAnalysis, exportAnalyses, importAnalyses } from '$lib/storage/analysis-history.ts';
import { currentGrowthSettings, snapshotKey, type AnalysisCapture, type AnalysisRecord, type AnalysisSettings, type GrowthSnapshot } from '$lib/models/analysis-history.ts';
import type { ExactOptimizationResult } from '$lib/engine/types.ts';
import type { ExactOptimizationResult, ScoreAchievementProbabilities } from '$lib/engine/types.ts';
export let capture: AnalysisCapture | null = null;
export let result: ExactOptimizationResult | null = null;
export let cardName: (id: string) => string;
export let currentGrowth: () => GrowthSnapshot;
export let evaluate: (settings: AnalysisSettings, growth: GrowthSnapshot) => Promise<{ result: ExactOptimizationResult; overallPower: number }>;
export let evaluate: (settings: AnalysisSettings, growth: GrowthSnapshot, includeAchievementProbabilities?: boolean) => Promise<{ result: ExactOptimizationResult; overallPower: number; achievementProbabilities?: ScoreAchievementProbabilities }>;
let name = '';
let records: AnalysisRecord[] = [];
let selected: AnalysisRecord | null = null;
@@ -19,6 +19,12 @@
let comparison: { before: { result: ExactOptimizationResult; overallPower: number }; after: { result: ExactOptimizationResult; overallPower: number }; changes: string[] } | null = null;
let phase = '';
let detail: ExactOptimizationResult | null = null;
let detailProbabilities: ScoreAchievementProbabilities | undefined;
function probabilityLabel(value: number): string {
if (value <= 0) return '0%';
if (value < 0.000001) return '<0.0001%';
return `${(value * 100).toFixed(value < 0.0001 ? 4 : value < 0.01 ? 2 : 1)}%`;
}
onMount(() => { void refresh(); });
async function refresh() {
try { records = await listAnalyses(); } catch { error = '저장 기록을 열지 못했습니다. 브라우저 저장 공간 사용을 허용해 주세요.'; }
@@ -79,8 +85,12 @@
}
async function showDetail() {
if (!selected || !savedGrowth) return;
busy = true; error = ''; phase = '당시 성장으로 상세 결과 재계산 중';
try { detail = (await evaluate(selected.settings, savedGrowth)).result; }
busy = true; error = ''; detail = null; detailProbabilities = undefined; phase = '당시 성장으로 상세 결과·고점 달성 확률 재계산 중';
try {
const evaluated = await evaluate(selected.settings, savedGrowth, true);
detail = evaluated.result;
detailProbabilities = evaluated.achievementProbabilities;
}
catch (e) { error = e instanceof Error ? e.message : '상세 결과를 계산하지 못했습니다.'; }
finally { busy = false; phase = ''; }
}
@@ -168,6 +178,15 @@
{#if detail}
<h4>당시 성장으로 상세 재분석한 결과</h4>
<p class="hint">노트별 그래프는 저장 용량을 줄이기 위해 보관하지 않습니다. 아래는 현재 계산식·악곡 데이터로 재계산한 결과이며 저장 원본 점수와 다를 수 있습니다.</p>
{#if detailProbabilities}
<div class="score-cards achievement-cards" aria-label="상세 재분석 고점 달성 확률">
<div><span>최고점 100%</span><strong>{probabilityLabel(detailProbabilities.maximum)}</strong></div>
<div><span>최고점의 99.5% 이상</span><strong>{probabilityLabel(detailProbabilities.atLeast995Percent)}</strong><small>{Math.ceil(detail.theoreticalMaxScore * 0.995).toLocaleString('ko-KR')}점 이상</small></div>
<div><span>최고점의 99% 이상</span><strong>{probabilityLabel(detailProbabilities.atLeast99Percent)}</strong><small>{Math.ceil(detail.theoreticalMaxScore * 0.99).toLocaleString('ko-KR')}점 이상</small></div>
<div><span>최고점의 95% 이상</span><strong>{probabilityLabel(detailProbabilities.atLeast95Percent)}</strong><small>{Math.ceil(detail.theoreticalMaxScore * 0.95).toLocaleString('ko-KR')}점 이상</small></div>
</div>
<p class="hint">모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99.5%·99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
{/if}
<AnalysisResultView result={detail} durationSeconds={selected.durationSeconds} {cardName} instanceId={`history-${selected.id}`} />
{/if}
</article>
@@ -204,6 +223,7 @@
button.delete-button { position: absolute; bottom: 9px; right: 10px; padding: 7px 10px; font-size: 11px; color: #9ca6b5; background: transparent; border-color: transparent; }
article { margin-top: 28px; border-top: 1px solid #394353; padding-top: 26px; } .detail-title { font-size: 23px; letter-spacing: -.03em; }
.score-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 20px 0; } .score-cards > div { display: grid; gap: 10px; padding: 18px; background: #25212e; border: 1px solid #3b3044; border-radius: 10px; } .score-cards span { color: #b2a6bc; font-size: 12px; } .score-cards strong { color: #f2b0cf; font-size: clamp(18px, 2.1vw, 26px); font-variant-numeric: tabular-nums; }
.achievement-cards { grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); }
.order-cards { list-style: none; padding: 0; display: grid; gap: 7px; } .order-cards li { display: flex; align-items: center; gap: 12px; background: #121820; border: 1px solid #303946; padding: 10px 12px; border-radius: 9px; } .slot { display: grid; place-items: center; width: 26px; height: 26px; flex-shrink: 0; color: #261724; background: #dd97b9; font-weight: 800; border-radius: 7px; } .order-cards strong { font-size: 13px; } .cooldown { margin-left: auto; color: #a9b5c5; font-size: 11px; white-space: nowrap; }
details { margin: 18px 0; border: 1px solid #333d4a; border-radius: 10px; padding: 14px; background: #141a22; } summary { cursor: pointer; font-size: 13px; color: #bbc6d4; } details p { font-size: 12px; }
.compare-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; } .comparison-heading { margin-top: 30px; padding-top: 22px; border-top: 1px solid #394353; }
+13
View File
@@ -1,10 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { compactResult, currentGrowthSettings, snapshotKey, type AnalysisSettings, type GrowthSnapshot } from '../models/analysis-history.ts';
import { parseAnalysisArchive } from '../storage/analysis-history.ts';
import type { ExactOptimizationResult } from './types.ts';
const growth: GrowthSnapshot = { collection: { a: { owned: true, awakening: 2 } }, memberBoards: { a: ['B1', 'G1'] }, boardCards: {}, memoryCount: 10, memberPowerUpPercent: 1 };
test('기록 상세 재분석은 저장 성장으로 확률을 요청하고 재계산 고점 기준으로 표시한다', () => {
const history = readFileSync(new URL('../components/AnalysisHistory.svelte', import.meta.url), 'utf8');
const page = readFileSync(new URL('../../routes/analysis/+page.svelte', import.meta.url), 'utf8');
assert.match(history, /evaluate\(selected\.settings, savedGrowth, true\)/);
assert.match(history, /detailProbabilities = evaluated\.achievementProbabilities/);
for (const key of ['maximum', 'atLeast99Percent', 'atLeast95Percent']) assert.ok(history.includes(`probabilityLabel(detailProbabilities.${key})`));
assert.match(history, /detail\.theoreticalMaxScore \* 0\.99/);
assert.match(history, /detail\.theoreticalMaxScore \* 0\.95/);
assert.match(page, /includeAchievementProbabilities = false/);
assert.match(page, /achievementProbabilities: event\.data\.achievementProbabilities/);
assert.match(page, /comparisonWorker\.postMessage\([^\n]*includeAchievementProbabilities/);
});
test('동일한 성장 상태는 입력 순서와 무관하게 공유하고 성장 차이는 구분한다', () => {
const reordered = { ...growth, memberBoards: { a: ['G1', 'B1'] } };
assert.equal(snapshotKey(growth), snapshotKey(reordered));
+1 -1
View File
@@ -34,7 +34,7 @@ test('전달 결과는 재탐색 없이 점수·확률을 유지하고 원래
const capture = { settings: combinationAnalysisSettings(state, cards.map(c => c.id), growth), growth,
title: 'song', durationSeconds: 8, overallPower: 100, analyzedAt: '2026-09-09T09:00:00Z' };
const transfer: AnalysisTransfer = { schemaVersion: 1, capture, result, cards, chart, directSongScoreBonusPercent: 10,
achievementProbabilities: { maximum: 0.1, atLeast99Percent: 0.2, atLeast95Percent: 0.4, samples: 25000 } };
achievementProbabilities: { maximum: 0.1, atLeast995Percent: 0.15, atLeast99Percent: 0.2, atLeast95Percent: 0.4, samples: 25000 } };
const loaded = parseAnalysisTransfer(JSON.stringify(transfer));
assert.deepEqual(loaded, transfer);
const score = calculateExactScore(loaded.result.order.map(id => loaded.cards.find(c => c.id === id)!), loaded.result.cooldownReductions, loaded.chart,
+16
View File
@@ -100,6 +100,7 @@ test('확정 발동이면 모든 달성 확률이 100%다', () => {
);
assert.deepEqual(result, {
maximum: 1,
atLeast995Percent: 1,
atLeast99Percent: 1,
atLeast95Percent: 1,
samples: 1_000
@@ -134,3 +135,18 @@ test('미발동 토글 시 현재 최고점 기준으로 확률을 계산하고
assert.deepEqual(calculateMaximumScoreProbabilityTimeline(cards, {}, chart([1.1]), options), [1]);
assert.equal(estimateScoreAchievementProbabilities(cards, {}, chart([1.1]), options).maximum, 1);
});
test('99.5% 경계 점수를 포함하고 99% 이상 점수와 구분한다', () => {
const estimate = (fallbackBoost: number) => estimateScoreAchievementProbabilities(
[card('strong', 100, 50), card('fallback', fallbackBoost, 100)],
{ strong: 0, fallback: 0 },
chart([1.1]),
{ perfectNoteScore: 10_000, sampleCount: 25_000, seed: 7 }
);
// 최고점 20,000점에서 19,900점은 포함하고 19,850점은 제외한다.
assert.equal(estimate(99).atLeast995Percent, 1);
const below = estimate(98.5);
assert.ok(Math.abs(below.atLeast995Percent - 0.5) < 0.02);
assert.equal(below.atLeast99Percent, 1);
});
+4
View File
@@ -230,8 +230,10 @@ export function estimateScoreAchievementProbabilities(
const samples = Math.max(1, Math.round(options.sampleCount ?? 25_000));
const random = randomGenerator(options.seed ?? hashSeed(cards, reductions));
const active = new Uint8Array(model.attempts.length);
let atLeast995 = 0;
let atLeast99 = 0;
let atLeast95 = 0;
const threshold995 = model.maximumScore * 0.995;
const threshold99 = model.maximumScore * 0.99;
const threshold95 = model.maximumScore * 0.95;
@@ -248,12 +250,14 @@ export function estimateScoreAchievementProbabilities(
}
score += group.scores[scoreIndex];
}
if (score >= threshold995) atLeast995 += 1;
if (score >= threshold99) atLeast99 += 1;
if (score >= threshold95) atLeast95 += 1;
}
return {
maximum: exactClauseProbability(model.maximumClauses, model.attempts),
atLeast995Percent: atLeast995 / samples,
atLeast99Percent: atLeast99 / samples,
atLeast95Percent: atLeast95 / samples,
samples
+1
View File
@@ -126,6 +126,7 @@ export type ExactScoreOptions = {
export type ScoreAchievementProbabilities = {
maximum: number;
atLeast995Percent: number;
atLeast99Percent: number;
atLeast95Percent: number;
samples: number;
+6 -5
View File
@@ -877,7 +877,7 @@
return structuredClone({ collection: value.collection ?? {}, memberBoards: value.memberBoards ?? {}, boardCards: value.boardCards ?? {}, memoryCount: value.memoryCount ?? 0, memberPowerUpPercent: value.memberPowerUpPercent ?? 0 });
}
async function evaluateHistory(settings: AnalysisSettings, growth: GrowthSnapshot): Promise<{ result: ExactOptimizationResult; overallPower: number }> {
async function evaluateHistory(settings: AnalysisSettings, growth: GrowthSnapshot, includeAchievementProbabilities = false): Promise<{ result: ExactOptimizationResult; overallPower: number; achievementProbabilities?: ScoreAchievementProbabilities }> {
const chartSummary = charts.find((chart) => chart.songId === settings.songId && chart.difficulty === settings.difficulty);
if (!chartSummary) throw new Error('현재 공개된 악곡 데이터에서 저장한 곡을 찾을 수 없습니다.');
const sources = settings.cardIds.map((id) => {
@@ -908,10 +908,10 @@
const finish = () => { comparisonWorker.terminate(); comparisonWorkers.delete(comparisonWorker); };
comparisonWorker.onerror = () => { finish(); reject(new Error('성장 비교 작업을 실행하지 못했습니다.')); };
comparisonWorker.onmessage = (event) => {
if (event.data.type === 'result') { finish(); resolve({ result: event.data.result, overallPower: power.overallPower }); }
if (event.data.type === 'result') { finish(); resolve({ result: event.data.result, overallPower: power.overallPower, achievementProbabilities: event.data.achievementProbabilities }); }
if (event.data.type === 'error') { finish(); reject(new Error(event.data.message)); }
};
comparisonWorker.postMessage({ type: 'optimize-exact', cards, chart, perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, chartSummary.scoreRatio), directSongScoreBonusPercent: effects.directSongScoreBonusPercent });
comparisonWorker.postMessage({ type: 'optimize-exact', cards, chart, perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, chartSummary.scoreRatio), directSongScoreBonusPercent: effects.directSongScoreBonusPercent, includeAchievementProbabilities });
});
}
@@ -1160,10 +1160,11 @@
{#if !simulatedResult && (mode === 'analysis' || achievementProbabilities)}
<div class="probability-grid">
<div><span>최고점 100%</span><strong>{probabilityLabel(achievementProbabilities?.maximum)}</strong></div>
<div><span>최고점의 99.5% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast995Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.995)}점 이상</small></div>
<div><span>최고점의 99% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast99Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.99)}점 이상</small></div>
<div><span>최고점의 95% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast95Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.95)}점 이상</small></div>
</div>
<p class="probability-guide">모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
<p class="probability-guide">모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99.5%·99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
{/if}
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
@@ -1418,7 +1419,7 @@
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
.score-summary span { color: #a9b1be; font-size: 14px; }
.score-summary strong { color: #f2a3c4; font-size: 20px; }
.probability-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; }
.probability-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: 8px; margin-top: 8px; }
.probability-grid > div { display: grid; gap: 7px; padding: 16px; border: 1px solid #303744; border-radius: 10px; background: #171d25; }
.probability-grid span { color: #9ca5b3; font-size: 14px; }
.probability-grid strong { color: #e5e9ef; font-size: 20px; }