feat: show 99.5 percent score achievement probability
Publish GitHub Pages output / publish (push) Successful in 35s
Publish GitHub Pages output / publish (push) Successful in 35s
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -126,6 +126,7 @@ export type ExactScoreOptions = {
|
||||
|
||||
export type ScoreAchievementProbabilities = {
|
||||
maximum: number;
|
||||
atLeast995Percent: number;
|
||||
atLeast99Percent: number;
|
||||
atLeast95Percent: number;
|
||||
samples: number;
|
||||
|
||||
Reference in New Issue
Block a user