From 98ef813cb5955d9c0d4c7a202d1aca2e72528cdc Mon Sep 17 00:00:00 2001 From: dal4segno Date: Mon, 7 Sep 2026 14:07:48 +0900 Subject: [PATCH] feat: show score achievement probabilities in leader rankings --- UPDATES.md | 2 + src/lib/components/LeaderSongRanking.svelte | 45 ++++++++++++++++----- src/lib/workers/score-optimizer.worker.ts | 1 + src/routes/analysis/+page.svelte | 4 +- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/UPDATES.md b/UPDATES.md index d7cf959..5950687 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -10,6 +10,8 @@ updatedAt: 2026-09-07T13:44:23+09:00 리더와 분석 조건, 합산 레이팅과 상위 3곡을 요약해 보여주며, 모바일에서는 추천곡을 카드 형태로 확인할 수 있습니다. 계산 기준은 펼쳐서 확인할 수 있습니다. +추천곡마다 최고점 및 최고점의 99%·95% 이상 달성 확률을 표시합니다. 상세 배치에서도 같은 확률을 확인할 수 있으며, 최고점은 정확 계산, 99%·95% 이상은 25,000회 반복 계산을 사용합니다. + ‘최대 컴퓨팅 자원 활용’을 켜면 기기의 논리 코어 수에 맞춰 여러 채보를 동시에 계산합니다. 동시 계산 수와 전체 진행률을 표시하며, 취소하거나 입력 조건을 바꾸면 모든 계산을 중단합니다. ### 배치 분석의 인게임 1노트 점수 표시 diff --git a/src/lib/components/LeaderSongRanking.svelte b/src/lib/components/LeaderSongRanking.svelte index 46e5612..1b7bc3b 100644 --- a/src/lib/components/LeaderSongRanking.svelte +++ b/src/lib/components/LeaderSongRanking.svelte @@ -4,12 +4,13 @@ import { loadExactSongChart } from '$lib/data/chart-loader.ts'; import { perfectNoteScoreFromPower } from '$lib/engine/score.ts'; import { leaderRating, leaderRatingCharts, rankSongCharts, rankSongs, songRating } from '$lib/engine/song-rating.ts'; - import type { ExactOptimizationResult, OptimizerCard } from '$lib/engine/types.ts'; + import type { ExactOptimizationResult, OptimizerCard, ScoreAchievementProbabilities } from '$lib/engine/types.ts'; import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts'; type Chart = (typeof catalog.charts)[number]; type Context = { cards: OptimizerCard[]; overallPower: number; directSongScoreBonusPercent: number }; - type Row = Chart & { result: ExactOptimizationResult; overallPower: number; theoreticalMaxScore: number; expectedScore: number }; + type Evaluation = { result: ExactOptimizationResult; achievementProbabilities: ScoreAchievementProbabilities }; + type Row = Chart & Evaluation & { overallPower: number; theoreticalMaxScore: number; expectedScore: number }; export let charts: Chart[]; export let leaderId: string; export let leaderName: string; @@ -17,7 +18,7 @@ export let inputKey: string; export let disabled = false; export let getContext: (chart: Chart) => Context; - export let onSelect: (chart: Chart, result: ExactOptimizationResult, overallPower: number) => void; + export let onSelect: (chart: Chart, result: ExactOptimizationResult, overallPower: number, probabilities: ScoreAchievementProbabilities) => void; const difficulty = 'both'; let running = false; @@ -100,7 +101,7 @@ try { const chart = await loadExactSongChart(summary.path); if (id !== runId) return; - const result = await new Promise((resolve, reject) => { + const { result, achievementProbabilities } = await new Promise((resolve, reject) => { rejectJob = reject; pendingJobs.add(reject); jobWorker = createScoreOptimizerWorker(); @@ -110,19 +111,22 @@ if (event.data.type === 'progress') { const progress = event.data.progress; activeJobs = { ...activeJobs, [jobIndex]: { title, - fraction: Math.min(1, Math.max(0, progress.evaluatedCandidates / Math.max(1, progress.totalCandidates))) } }; - } else if (event.data.type === 'result') resolve(event.data.result); + fraction: .9 * Math.min(1, Math.max(0, progress.evaluatedCandidates / Math.max(1, progress.totalCandidates))) } }; + } else if (event.data.type === 'probability-start') { + activeJobs = { ...activeJobs, [jobIndex]: { title: `${title} · 확률 계산`, fraction: .9 } }; + } else if (event.data.type === 'result') resolve({ result: event.data.result, achievementProbabilities: event.data.achievementProbabilities }); else if (event.data.type === 'error') reject(new Error(event.data.message)); }; jobWorker.onerror = () => reject(new Error('계산 작업을 실행하지 못했습니다.')); jobWorker.postMessage({ type: 'optimize-exact', cards: context.cards, chart, perfectNoteScore: perfectNoteScoreFromPower(context.overallPower, chart.scoreRatio), - directSongScoreBonusPercent: context.directSongScoreBonusPercent + directSongScoreBonusPercent: context.directSongScoreBonusPercent, + includeAchievementProbabilities: true }); }); if (id !== runId) return; - rows = [...rows, { ...summary, result, overallPower: context.overallPower, + rows = [...rows, { ...summary, result, achievementProbabilities, overallPower: context.overallPower, theoreticalMaxScore: result.theoreticalMaxScore, expectedScore: result.expectedScore }]; } catch (error) { if (id !== runId) return; @@ -154,6 +158,13 @@ } const format = (value: number) => Math.round(value).toLocaleString('ko-KR'); + function probabilityLabel(value: number): string { + if (value <= 0) return '0%'; + if (value < 0.000001) return '<0.0001%'; + if (value < 0.0001) return `${(value * 100).toFixed(4)}%`; + if (value < 0.01) return `${(value * 100).toFixed(2)}%`; + return `${(value * 100).toFixed(1)}%`; + } function changePage(value: number): void { pageNumber = Math.max(1, Math.min(pageCount, value)); @@ -218,14 +229,19 @@

추천 순위 {rankings.length}

예상 고점 레이팅순 · 5개씩 보기

- + {#each pageRows as row, index} - + + {/each}
순위곡 / 난이도고점 레이팅예상 고점해당 배치 평균상세 배치
순위곡 / 난이도고점 레이팅예상 고점해당 배치 평균점수 달성 확률상세 배치
{(pageNumber - 1) * pageSize + index + 1} {row.title}
{row.difficulty === 'expert' ? 'Expert' : 'Hard'} {row.difficultyLevel}{#if topSongs.includes(row)}합산 대상{/if}{#if row.scoreCalibrationSource !== 'formula-v1' && row.scoreCalibrationSource !== 'measured'}점수 계수 추정{/if}
레이팅{format(songRating(row.theoreticalMaxScore))} 예상 고점{format(row.theoreticalMaxScore)} 배치 평균{format(row.expectedScore)}
+
최고점{probabilityLabel(row.achievementProbabilities.maximum)}
+
99% 이상{probabilityLabel(row.achievementProbabilities.atLeast99Percent)}
+
95% 이상{probabilityLabel(row.achievementProbabilities.atLeast95Percent)}
+
@@ -251,6 +267,7 @@

멤버 5명과 리더·의상·성장 설정을 반영하고, 곡마다 스페셜 순서와 허용된 빈도 감소를 최적화합니다. 가창 보너스는 모든 곡에 10%를 적용합니다.

ALL PERFECT·스킬 최대 발동 기준의 예상 고점 순위입니다. 곡별 Hard·Expert를 각각 표시하며, 최대 20개 결과를 5개씩 확인할 수 있습니다. 상위 3곡 합계는 같은 곡에서 고점이 높은 결과 하나만 집계합니다.

+

점수 달성 확률은 해당 추천 배치에서 모든 노트를 PERFECT로 처리할 때의 액티브 발동 확률을 반영합니다. 최고점 확률은 정확 계산, 최고점의 99%·95% 이상 확률은 25,000회 반복 계산값입니다.

곡 레이팅 = 점수 ÷ 5,000 올림 · 계산 기준 (비공식 연구 자료) ↗. 실제 플레이 결과와 차이가 있을 수 있습니다. 가창 멤버가 등록되지 않은 곡은 제외합니다.

@@ -353,6 +370,11 @@ .chart-rating strong { color: #f1b0ce; font-size: 23px; letter-spacing: -.025em; } .maximum-score { color: #cdd5e1; } .average-score { color: #8e9aaf; } + .achievement-grid { display: grid; grid-template-columns: repeat(3, minmax(56px, 1fr)); gap: 10px; text-align: right; } + .achievement-grid > div { display: grid; gap: 7px; } + .achievement-grid span { color: #9690a6; font-size: 10px; } + .achievement-grid strong { color: #cbb4db; font-size: 12px; font-weight: 650; } + .achievement-grid > div:first-child strong { color: #f1b0ce; } .row-action button { padding: 9px 11px; background: transparent; white-space: nowrap; font-size: 11px; } .row-action button > span { margin-left: 8px; color: #bd8ea8; } .mobile-label { display: none; } @@ -431,6 +453,9 @@ .row-action { grid-column: 3; grid-row: 2 / 4; align-self: end; } .row-action button { padding: 7px 8px; font-size: 10px; } .row-action button > span { display: none; } + .probability-cell { grid-column: 1 / -1; grid-row: 4; padding-top: 11px; margin-top: 5px; border-top: 1px solid #3c3342; } + .achievement-grid { gap: 12px; text-align: center; } + .achievement-grid > div { gap: 5px; } .list-footer { gap: 5px; } .pagination { gap: 2px; } .pagination button { width: 29px; height: 31px; } diff --git a/src/lib/workers/score-optimizer.worker.ts b/src/lib/workers/score-optimizer.worker.ts index c371c23..d846e93 100644 --- a/src/lib/workers/score-optimizer.worker.ts +++ b/src/lib/workers/score-optimizer.worker.ts @@ -79,6 +79,7 @@ self.onmessage = (event: MessageEvent, rankingResult: ExactOptimizationResult, power: number) { + async function showRatingResult(chart: NonNullable, rankingResult: ExactOptimizationResult, power: number, probabilities: ScoreAchievementProbabilities) { ratingResultKey = ratingInputKey; selectedSongId = chart.songId; difficulty = chart.difficulty as DifficultyKey; result = rankingResult; - achievementProbabilities = undefined; + achievementProbabilities = probabilities; clearConnectionOptimizationProposal(); await tick(); resultCapture = structuredClone({