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
+2
View File
@@ -10,6 +10,8 @@ updatedAt: 2026-09-07T13:44:23+09:00
리더와 분석 조건, 합산 레이팅과 상위 3곡을 요약해 보여주며, 모바일에서는 추천곡을 카드 형태로 확인할 수 있습니다. 계산 기준은 펼쳐서 확인할 수 있습니다.
추천곡마다 최고점 및 최고점의 99%·95% 이상 달성 확률을 표시합니다. 상세 배치에서도 같은 확률을 확인할 수 있으며, 최고점은 정확 계산, 99%·95% 이상은 25,000회 반복 계산을 사용합니다.
‘최대 컴퓨팅 자원 활용’을 켜면 기기의 논리 코어 수에 맞춰 여러 채보를 동시에 계산합니다. 동시 계산 수와 전체 진행률을 표시하며, 취소하거나 입력 조건을 바꾸면 모든 계산을 중단합니다.
### 배치 분석의 인게임 1노트 점수 표시
+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; }
@@ -79,6 +79,7 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
let achievementProbabilities: ScoreAchievementProbabilities | undefined;
if (event.data.includeAchievementProbabilities) {
self.postMessage({ type: 'probability-start' });
const orderedCards = cardsForResult(event.data.cards, result);
achievementProbabilities = estimateScoreAchievementProbabilities(
orderedCards,
+2 -2
View File
@@ -522,12 +522,12 @@
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent };
}
async function showRatingResult(chart: NonNullable<typeof selectedChart>, rankingResult: ExactOptimizationResult, power: number) {
async function showRatingResult(chart: NonNullable<typeof selectedChart>, 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({