From 167c9330321e8d9540670af63e006d819fafbbff Mon Sep 17 00:00:00 2001 From: dal4segno Date: Fri, 25 Sep 2026 08:22:37 +0900 Subject: [PATCH] feat: show 99.5 percent score achievement probability --- UPDATES.md | 8 ++++++- src/lib/components/AnalysisHistory.svelte | 28 +++++++++++++++++++---- src/lib/engine/analysis-history.test.ts | 13 +++++++++++ src/lib/engine/analysis-transfer.test.ts | 2 +- src/lib/engine/score-probability.test.ts | 16 +++++++++++++ src/lib/engine/score-probability.ts | 4 ++++ src/lib/engine/types.ts | 1 + src/routes/analysis/+page.svelte | 11 +++++---- 8 files changed, 72 insertions(+), 11 deletions(-) diff --git a/UPDATES.md b/UPDATES.md index 65cfae2..04b840a 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -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 악곡 추가 diff --git a/src/lib/components/AnalysisHistory.svelte b/src/lib/components/AnalysisHistory.svelte index c24601c..dd43394 100644 --- a/src/lib/components/AnalysisHistory.svelte +++ b/src/lib/components/AnalysisHistory.svelte @@ -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}

당시 성장으로 상세 재분석한 결과

노트별 그래프는 저장 용량을 줄이기 위해 보관하지 않습니다. 아래는 현재 계산식·악곡 데이터로 재계산한 결과이며 저장 원본 점수와 다를 수 있습니다.

+ {#if detailProbabilities} +
+
최고점 100%{probabilityLabel(detailProbabilities.maximum)}
+
최고점의 99.5% 이상{probabilityLabel(detailProbabilities.atLeast995Percent)}{Math.ceil(detail.theoreticalMaxScore * 0.995).toLocaleString('ko-KR')}점 이상
+
최고점의 99% 이상{probabilityLabel(detailProbabilities.atLeast99Percent)}{Math.ceil(detail.theoreticalMaxScore * 0.99).toLocaleString('ko-KR')}점 이상
+
최고점의 95% 이상{probabilityLabel(detailProbabilities.atLeast95Percent)}{Math.ceil(detail.theoreticalMaxScore * 0.95).toLocaleString('ko-KR')}점 이상
+
+

모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99.5%·99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.

+ {/if} {/if} @@ -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; } diff --git a/src/lib/engine/analysis-history.test.ts b/src/lib/engine/analysis-history.test.ts index 297fd96..f827368 100644 --- a/src/lib/engine/analysis-history.test.ts +++ b/src/lib/engine/analysis-history.test.ts @@ -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)); diff --git a/src/lib/engine/analysis-transfer.test.ts b/src/lib/engine/analysis-transfer.test.ts index afde0fd..2c4f8bd 100644 --- a/src/lib/engine/analysis-transfer.test.ts +++ b/src/lib/engine/analysis-transfer.test.ts @@ -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, diff --git a/src/lib/engine/score-probability.test.ts b/src/lib/engine/score-probability.test.ts index 2dcc56a..af8291e 100644 --- a/src/lib/engine/score-probability.test.ts +++ b/src/lib/engine/score-probability.test.ts @@ -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); +}); diff --git a/src/lib/engine/score-probability.ts b/src/lib/engine/score-probability.ts index 61a54f8..9710d33 100644 --- a/src/lib/engine/score-probability.ts +++ b/src/lib/engine/score-probability.ts @@ -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 diff --git a/src/lib/engine/types.ts b/src/lib/engine/types.ts index db32939..50ede42 100644 --- a/src/lib/engine/types.ts +++ b/src/lib/engine/types.ts @@ -126,6 +126,7 @@ export type ExactScoreOptions = { export type ScoreAchievementProbabilities = { maximum: number; + atLeast995Percent: number; atLeast99Percent: number; atLeast95Percent: number; samples: number; diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index 4b417fa..4378742 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -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)}
최고점 100%{probabilityLabel(achievementProbabilities?.maximum)}
+
최고점의 99.5% 이상{probabilityLabel(achievementProbabilities?.atLeast995Percent)}{scoreThreshold(result.theoreticalMaxScore, 0.995)}점 이상
최고점의 99% 이상{probabilityLabel(achievementProbabilities?.atLeast99Percent)}{scoreThreshold(result.theoreticalMaxScore, 0.99)}점 이상
최고점의 95% 이상{probabilityLabel(achievementProbabilities?.atLeast95Percent)}{scoreThreshold(result.theoreticalMaxScore, 0.95)}점 이상
-

모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.

+

모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99.5%·99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.

{/if}
@@ -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; }