feat: show score achievement probabilities in leader rankings

This commit is contained in:
2026-09-07 14:07:48 +09:00
parent 907c11b0e6
commit 98ef813cb5
4 changed files with 40 additions and 12 deletions
+35 -10
View File
@@ -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<ExactOptimizationResult>((resolve, reject) => {
const { result, achievementProbabilities } = await new Promise<Evaluation>((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 @@
<div class="list-heading"><h3>추천 순위 <span>{rankings.length}</span></h3><p>예상 고점 레이팅순 <span>·</span> 5개씩 보기</p></div>
<div class="table-scroll" bind:this={resultList}>
<table>
<thead><tr><th scope="col">순위</th><th scope="col">곡 / 난이도</th><th scope="col">고점 레이팅</th><th scope="col">예상 고점</th><th scope="col">해당 배치 평균</th><th scope="col"><span class="sr-only">상세 배치</span></th></tr></thead>
<thead><tr><th scope="col">순위</th><th scope="col">곡 / 난이도</th><th scope="col">고점 레이팅</th><th scope="col">예상 고점</th><th scope="col">해당 배치 평균</th><th scope="col">점수 달성 확률</th><th scope="col"><span class="sr-only">상세 배치</span></th></tr></thead>
<tbody>{#each pageRows as row, index}<tr class:top={topSongs.includes(row)}>
<td class="rank-cell"><span class="rank-number" class:first={(pageNumber - 1) * pageSize + index === 0}>{(pageNumber - 1) * pageSize + index + 1}</span></td>
<td class="song"><strong>{row.title}</strong><div class="song-meta"><span class="difficulty" class:expert={row.difficulty === 'expert'}>{row.difficulty === 'expert' ? 'Expert' : 'Hard'} <b>{row.difficultyLevel}</b></span>{#if topSongs.includes(row)}<span class="counted">합산 대상</span>{/if}{#if row.scoreCalibrationSource !== 'formula-v1' && row.scoreCalibrationSource !== 'measured'}<span class="estimated">점수 계수 추정</span>{/if}</div></td>
<td class="chart-rating"><span class="mobile-label">레이팅</span><strong>{format(songRating(row.theoreticalMaxScore))}</strong></td>
<td class="maximum-score"><span class="mobile-label">예상 고점</span>{format(row.theoreticalMaxScore)}</td>
<td class="average-score"><span class="mobile-label">배치 평균</span>{format(row.expectedScore)}</td>
<td class="row-action"><button type="button" disabled={stale || disabled || running} on:click={() => onSelect(row, row.result, row.overallPower)}>배치 보기 <span aria-hidden="true">→</span></button></td>
<td class="probability-cell"><div class="achievement-grid">
<div title={`${format(row.theoreticalMaxScore)}점 달성 확률`}><span>최고점</span><strong>{probabilityLabel(row.achievementProbabilities.maximum)}</strong></div>
<div title={`${format(Math.ceil(row.theoreticalMaxScore * .99))}점 이상 달성 확률`}><span>99% 이상</span><strong>{probabilityLabel(row.achievementProbabilities.atLeast99Percent)}</strong></div>
<div title={`${format(Math.ceil(row.theoreticalMaxScore * .95))}점 이상 달성 확률`}><span>95% 이상</span><strong>{probabilityLabel(row.achievementProbabilities.atLeast95Percent)}</strong></div>
</div></td>
<td class="row-action"><button type="button" disabled={stale || disabled || running} on:click={() => onSelect(row, row.result, row.overallPower, row.achievementProbabilities)}>배치 보기 <span aria-hidden="true">→</span></button></td>
</tr>{/each}</tbody>
</table>
</div>
@@ -251,6 +267,7 @@
<div>
<p>멤버 5명과 리더·의상·성장 설정을 반영하고, 곡마다 스페셜 순서와 허용된 빈도 감소를 최적화합니다. 가창 보너스는 모든 곡에 10%를 적용합니다.</p>
<p>ALL PERFECT·스킬 최대 발동 기준의 예상 고점 순위입니다. 곡별 Hard·Expert를 각각 표시하며, 최대 20개 결과를 5개씩 확인할 수 있습니다. 상위 3곡 합계는 같은 곡에서 고점이 높은 결과 하나만 집계합니다.</p>
<p>점수 달성 확률은 해당 추천 배치에서 모든 노트를 PERFECT로 처리할 때의 액티브 발동 확률을 반영합니다. 최고점 확률은 정확 계산, 최고점의 99%·95% 이상 확률은 25,000회 반복 계산값입니다.</p>
<p>곡 레이팅 = 점수 ÷ 5,000 올림 · <a href="https://dreams.wf-calc.net/wiki/live" target="_blank" rel="noreferrer">계산 기준 (비공식 연구 자료) ↗</a>. 실제 플레이 결과와 차이가 있을 수 있습니다. 가창 멤버가 등록되지 않은 곡은 제외합니다.</p>
</div>
</details>
@@ -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; }