Compare commits

..
4 Commits
Author SHA1 Message Date
dal4segno 0af64052c6 feat: support every song difficulty including Easy and Normal
Publish GitHub Pages output / publish (push) Successful in 41s
2026-09-07 14:18:10 +09:00
dal4segno 8f97683271 fix: shorten update notes and contain entry content in cards 2026-09-07 14:15:39 +09:00
dal4segno 98ef813cb5 feat: show score achievement probabilities in leader rankings 2026-09-07 14:07:48 +09:00
dal4segno 907c11b0e6 feat: add leader rating song recommendations 2026-09-07 13:55:55 +09:00
412 changed files with 12895 additions and 36 deletions
+9 -1
View File
@@ -1,9 +1,17 @@
--- ---
updatedAt: 2026-09-07T00:16:12+09:00 updatedAt: 2026-09-07T14:17:02+09:00
--- ---
## 2026년 9월 7일 ## 2026년 9월 7일
### 모든 악곡 난이도 지원
`NEW` 모든 악곡의 Easy·Normal 채보를 추가했습니다. 배치 분석과 조합 탐색에서 네 난이도를 선택할 수 있으며, 분석 기록 저장·복원과 리더 레이팅 곡 추천에도 모든 난이도를 반영합니다.
### 리더 레이팅 곡 추천
`NEW` 입력한 편성에 맞는 리더 가창곡을 레이팅순으로 최대 20개 추천합니다. 5개씩 보기, 상위 3곡 합계, 최고점·99%·95% 달성 확률과 상세 배치를 제공하며 병렬 계산을 지원합니다. 가창 보너스는 10%로 고정합니다.
### 배치 분석의 인게임 1노트 점수 표시 ### 배치 분석의 인게임 1노트 점수 표시
`NEW` 배치 분석에서 기본 1노트 점수와 가창 보너스를 합산한 인게임 1노트 점수를 함께 확인할 수 있습니다. 일반 PERFECT·스킬/콤보 보너스 제외 기준이며, 가창 보너스 덮어쓰기 값도 즉시 반영합니다. `NEW` 배치 분석에서 기본 1노트 점수와 가창 보너스를 합산한 인게임 1노트 점수를 함께 확인할 수 있습니다. 일반 PERFECT·스킬/콤보 보너스 제외 기준이며, 가창 보너스 덮어쓰기 값도 즉시 반영합니다.
File diff suppressed because it is too large Load Diff
+3
View File
@@ -4,6 +4,7 @@
import HomePage from '../src/routes/+page.svelte'; import HomePage from '../src/routes/+page.svelte';
import CardsPage from '../src/routes/cards/+page.svelte'; import CardsPage from '../src/routes/cards/+page.svelte';
import AnalysisPage from '../src/routes/analysis/+page.svelte'; import AnalysisPage from '../src/routes/analysis/+page.svelte';
import RatingPage from '../src/routes/rating/+page.svelte';
import CombinationPage from '../src/routes/combination/+page.svelte'; import CombinationPage from '../src/routes/combination/+page.svelte';
import EventPage from '../src/routes/event/+page.svelte'; import EventPage from '../src/routes/event/+page.svelte';
import DataPage from '../src/routes/data/+page.svelte'; import DataPage from '../src/routes/data/+page.svelte';
@@ -18,6 +19,7 @@
['/', HomePage], ['/', HomePage],
['/cards', CardsPage], ['/cards', CardsPage],
['/analysis', AnalysisPage], ['/analysis', AnalysisPage],
['/rating', RatingPage],
['/combination', CombinationPage], ['/combination', CombinationPage],
['/event', EventPage], ['/event', EventPage],
['/data', DataPage], ['/data', DataPage],
@@ -78,6 +80,7 @@
<nav aria-label="주요 메뉴"> <nav aria-label="주요 메뉴">
<a href="#/cards" aria-current={currentPath.startsWith('/cards') ? 'page' : undefined}>카드 목록</a> <a href="#/cards" aria-current={currentPath.startsWith('/cards') ? 'page' : undefined}>카드 목록</a>
<a href="#/analysis" aria-current={currentPath.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a> <a href="#/analysis" aria-current={currentPath.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
<a href="#/rating" aria-current={currentPath.startsWith('/rating') ? 'page' : undefined}>리더 레이팅</a>
<a href="#/combination" aria-current={currentPath.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a> <a href="#/combination" aria-current={currentPath.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
{#if showEventTab}<a href="#/event" aria-current={currentPath.startsWith('/event') ? 'page' : undefined}>이벤트 Pt</a>{/if} {#if showEventTab}<a href="#/event" aria-current={currentPath.startsWith('/event') ? 'page' : undefined}>이벤트 Pt</a>{/if}
<a href="#/data" aria-current={currentPath.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a> <a href="#/data" aria-current={currentPath.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
+2 -2
View File
@@ -6,7 +6,7 @@ import { formulaScoreMetadata } from './song-score-formulas.mjs';
const sourceOrigin = process.argv[2] ?? 'https://dreams.wf-calc.net'; const sourceOrigin = process.argv[2] ?? 'https://dreams.wf-calc.net';
const outputDirectory = path.resolve('static/data/charts'); const outputDirectory = path.resolve('static/data/charts');
const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json'); const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json');
const selectedDifficulties = new Set(['HARD', 'EXPERT']); const selectedDifficulties = new Set(['EASY', 'NORMAL', 'HARD', 'EXPERT']);
const chartScoreWeightCoefficient = 0.3713416563352296; const chartScoreWeightCoefficient = 0.3713416563352296;
const scoreCalibrations = JSON.parse(await readFile(path.resolve('data/score-calibrations.json'), 'utf8')); const scoreCalibrations = JSON.parse(await readFile(path.resolve('data/score-calibrations.json'), 'utf8'));
const scoreCalibrationByKey = new Map(scoreCalibrations.charts.map((row) => [ const scoreCalibrationByKey = new Map(scoreCalibrations.charts.map((row) => [
@@ -219,7 +219,7 @@ for (const match of simulatorModule.matchAll(importPattern)) {
if (selectedDifficulties.has(difficulty)) imports.push({ songId, difficulty, difficultyLevel, chartFile, assetFile }); if (selectedDifficulties.has(difficulty)) imports.push({ songId, difficulty, difficultyLevel, chartFile, assetFile });
} }
if (imports.length === 0) throw new Error('수집할 Hard/Expert 채보 모듈을 찾지 못했습니다.'); if (imports.length === 0) throw new Error('수집할 채보 모듈을 찾지 못했습니다.');
await mkdir(outputDirectory, { recursive: true }); await mkdir(outputDirectory, { recursive: true });
let completed = 0; let completed = 0;
+1 -1
View File
@@ -126,7 +126,7 @@
{#each records as record (record.id)} {#each records as record (record.id)}
<li class:selected={selected?.id === record.id}> <li class:selected={selected?.id === record.id}>
<button class="record" disabled={busy} on:click={() => open(record)} aria-expanded={selected?.id === record.id}> <button class="record" disabled={busy} on:click={() => open(record)} aria-expanded={selected?.id === record.id}>
<span class="record-meta"><span class="difficulty">{record.settings.difficulty === 'expert' ? 'EXPERT' : 'HARD'}</span><span>{new Date(record.analyzedAt).toLocaleDateString('ko-KR')}</span></span> <span class="record-meta"><span class="difficulty">{record.settings.difficulty.toUpperCase()}</span><span>{new Date(record.analyzedAt).toLocaleDateString('ko-KR')}</span></span>
<strong class="record-name">{record.name}</strong><span class="song-name">{record.title}</span> <strong class="record-name">{record.name}</strong><span class="song-name">{record.title}</span>
<span class="members">{#each record.settings.cardIds as id}<span>{cardName(id)}</span>{/each}</span> <span class="members">{#each record.settings.cardIds as id}<span>{cardName(id)}</span>{/each}</span>
<span class="record-scores"><span><small>평균 점수</small><b>{record.result.expectedScore.toLocaleString('ko-KR')}</b></span><span><small>이론상 고점</small><b>{record.result.theoreticalMaxScore.toLocaleString('ko-KR')}</b></span></span> <span class="record-scores"><span><small>평균 점수</small><b>{record.result.expectedScore.toLocaleString('ko-KR')}</b></span><span><small>이론상 고점</small><b>{record.result.theoreticalMaxScore.toLocaleString('ko-KR')}</b></span></span>
+472
View File
@@ -0,0 +1,472 @@
<script lang="ts">
import { difficultyLabel } from '$lib/data/difficulty.ts';
import { onDestroy, onMount } from 'svelte';
import type catalog from '$lib/data/exact-chart-catalog.json';
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, 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 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;
export let now: number;
export let inputKey: string;
export let disabled = false;
export let getContext: (chart: Chart) => Context;
export let onSelect: (chart: Chart, result: ExactOptimizationResult, overallPower: number, probabilities: ScoreAchievementProbabilities) => void;
const difficulty = 'all';
let running = false;
let rows: Row[] = [];
let completed = 0;
let total = 0;
let maximizeCompute = false;
let reportedCores = 1;
let activeJobs: Record<number, { title: string; fraction: number }> = {};
let message = '';
let failures: string[] = [];
let snapshotKey = '';
let snapshotLeader = '';
const workers = new Set<Worker>();
const pendingJobs = new Set<(reason: Error) => void>();
let runId = 0;
let pageNumber = 1;
const pageSize = 5;
let resultList: HTMLDivElement;
onMount(() => {
const cores = navigator.hardwareConcurrency || 4;
reportedCores = Number.isFinite(cores) ? Math.max(1, Math.floor(cores)) : 1;
});
$: targets = leaderRatingCharts(charts, leaderId, difficulty, now);
$: parallelCount = Math.min(targets.length, reportedCores);
$: activeEntries = Object.values(activeJobs);
$: fraction = activeEntries.reduce((sum, job) => sum + job.fraction, 0);
$: currentTitle = activeEntries.length
? `${activeEntries[0].title}${activeEntries.length > 1 ? ` 외 ${activeEntries.length - 1}개` : ''}` : '';
$: currentKey = JSON.stringify([inputKey, difficulty, targets.map((chart) => chart.path)]);
$: stale = snapshotKey !== '' && snapshotKey !== currentKey;
$: rankings = rankSongCharts(rows).slice(0, 20);
$: topSongs = rankSongs(rows).slice(0, 3);
$: pageCount = Math.max(1, Math.ceil(rankings.length / pageSize));
$: if (pageNumber > pageCount) pageNumber = pageCount;
$: pageRows = rankings.slice((pageNumber - 1) * pageSize, pageNumber * pageSize);
$: if (running && stale) cancel('입력 조건이 변경되어 계산을 중단했습니다. 다시 검색해 주세요.');
function cancel(reason = '계산을 취소했습니다. 완료된 곡까지만 표시합니다.'): void {
runId += 1;
for (const worker of workers) worker.terminate();
workers.clear();
for (const reject of pendingJobs) reject(new Error(reason));
pendingJobs.clear();
activeJobs = {};
running = false;
message = reason;
}
onDestroy(() => cancel());
async function search(): Promise<void> {
if (running || disabled || targets.length === 0) return;
const id = ++runId;
snapshotKey = currentKey;
snapshotLeader = leaderName;
rows = [];
pageNumber = 1;
failures = [];
message = '';
completed = 0;
activeJobs = {};
total = targets.length;
running = true;
try {
// Capture every song's board/power context before the first async boundary.
const jobs = targets.map((chart) => ({ chart, context: structuredClone(getContext(chart)) }));
const workerCount = maximizeCompute ? Math.min(jobs.length, reportedCores) : 1;
let nextJob = 0;
async function consume(): Promise<void> {
while (id === runId && nextJob < jobs.length) {
const jobIndex = nextJob++;
const { chart: summary, context } = jobs[jobIndex];
const title = `${summary.title} · ${difficultyLabel(summary.difficulty)}`;
activeJobs = { ...activeJobs, [jobIndex]: { title, fraction: 0 } };
let jobWorker: Worker | null = null;
let rejectJob: ((reason: Error) => void) | null = null;
try {
const chart = await loadExactSongChart(summary.path);
if (id !== runId) return;
const { result, achievementProbabilities } = await new Promise<Evaluation>((resolve, reject) => {
rejectJob = reject;
pendingJobs.add(reject);
jobWorker = createScoreOptimizerWorker();
workers.add(jobWorker);
jobWorker.onmessage = (event) => {
if (id !== runId) return;
if (event.data.type === 'progress') {
const progress = event.data.progress;
activeJobs = { ...activeJobs, [jobIndex]: { title,
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,
includeAchievementProbabilities: true
});
});
if (id !== runId) return;
rows = [...rows, { ...summary, result, achievementProbabilities, overallPower: context.overallPower,
theoreticalMaxScore: result.theoreticalMaxScore, expectedScore: result.expectedScore }];
} catch (error) {
if (id !== runId) return;
failures = [...failures, `${title}: ${error instanceof Error ? error.message : '계산 실패'}`];
} finally {
if (jobWorker) {
(jobWorker as Worker).terminate();
workers.delete(jobWorker);
}
if (rejectJob) pendingJobs.delete(rejectJob);
if (id === runId) {
const remaining = { ...activeJobs };
delete remaining[jobIndex];
activeJobs = remaining;
completed += 1;
}
}
}
}
await Promise.all(Array.from({ length: workerCount }, () => consume()));
if (id !== runId) return;
message = failures.length ? `계산 종료 · ${failures.length}개 채보 실패. 순위와 합계는 일부 결과입니다.` : '모든 대상 곡의 계산을 완료했습니다.';
} catch (error) {
if (id !== runId) return;
message = error instanceof Error ? error.message : '입력 조건을 확인해 주세요.';
} finally {
if (id === runId) running = false;
}
}
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));
resultList?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
</script>
<section class="ranking" aria-labelledby="leader-ranking-title">
<div class="heading">
<div class="section-heading"><span class="section-number">02</span><div><h2 id="leader-ranking-title">리더 레이팅 곡 추천</h2><p>지금 편성으로 도전할 곡을 찾아보세요.</p></div></div>
</div>
<div class="search-bar">
<div class="leader-summary"><span class="eyebrow">선택한 리더</span><strong>{leaderName}</strong></div>
<div class="conditions">
<div><span>가창곡</span><strong>{new Set(targets.map((chart) => chart.songId)).size}<small>곡</small></strong></div>
<div><span>분석 채보</span><strong>{targets.length}<small>개</small></strong></div>
<div><span>가창 보너스</span><strong class="bonus">10<small>%</small></strong></div>
</div>
<div class="actions">
<button class="primary" type="button" on:click={search} disabled={disabled || running || targets.length === 0}><span>{running ? '추천 곡 계산 중' : '레이팅 높은 곡 찾기'}</span><span aria-hidden="true">{running ? '···' : '↗'}</span></button>
{#if running}<button class="cancel" type="button" on:click={() => cancel()}>계산 취소</button>{/if}
</div>
</div>
<div class="compute-settings">
<label class="compute-option">
<input type="checkbox" bind:checked={maximizeCompute} disabled={running} aria-describedby="rating-compute-help">
<span class="compute-switch" aria-hidden="true"></span>
<strong>최대 컴퓨팅 자원 활용</strong>
<span class="compute-count">{maximizeCompute ? `최대 ${parallelCount}개 동시 계산` : '한 번에 1개 계산'}</span>
</label>
<p id="rating-compute-help">기기의 논리 코어 수에 맞춰 여러 채보를 동시에 계산합니다. 계산 결과는 동일하며, 사용 중 발열이나 브라우저 반응 저하가 생길 수 있습니다.</p>
</div>
{#if running}
<div class="progress-panel" role="status">
<div class="progress-heading"><span><i class="status-dot"></i>최적 배치를 비교하고 있어요</span><strong>{Math.round((completed + fraction) / Math.max(1, total) * 100)}<small>%</small></strong></div>
<progress aria-label="리더 레이팅 곡 계산 진행률" value={completed + fraction} max={total}></progress>
<div class="progress-caption"><span>{currentTitle || '채보 준비 중'}</span><span>{activeEntries.length}개 동시 계산 · {completed} / {total}개 완료</span></div>
</div>
{/if}
{#if message}<p class="status-message" role="status"><span class="status-dot"></span>{message}</p>{/if}
{#if stale}<p class="warning" role="status">입력 조건이 변경되었습니다. 아래는 이전 조건의 결과이므로 다시 검색해 주세요.</p>{/if}
{#if failures.length}<details class="failure-details"><summary>계산하지 못한 채보 {failures.length}개</summary><ul>{#each failures as failure}<li>{failure}</li>{/each}</ul></details>{/if}
{#if rankings.length}
<div class="rating-summary">
<div class="total">
<span class="eyebrow">{snapshotLeader} · 예상 리더 레이팅</span>
<div class="rating-value"><strong>{format(leaderRating(rows))}</strong><span>RATING</span></div>
<span class="total-caption">상위 {topSongs.length}곡 합산{running || completed < total || failures.length ? ' · 일부 결과' : ''}{stale ? ' · 이전 조건' : ''}</span>
</div>
<div class="contributors">
<div class="contributors-title"><span>레이팅에 합산되는 곡</span><small>곡별 최고 기록 1개</small></div>
<ol>{#each topSongs as row, index}<li>
<span class="contributor-index">{String(index + 1).padStart(2, '0')}</span>
<div><span class="contributor-title">{row.title}</span><small>{difficultyLabel(row.difficulty)} · Lv.{row.difficultyLevel}</small></div>
<strong>{format(songRating(row.theoreticalMaxScore))}</strong>
</li>{/each}</ol>
</div>
</div>
<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">점수 달성 확률</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'}>{difficultyLabel(row.difficulty)} <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="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>
<div class="list-footer">
<span class="range-label" role="status">{(pageNumber - 1) * pageSize + 1}–{Math.min(pageNumber * pageSize, rankings.length)} <span>/ {rankings.length}개 채보</span></span>
<nav class="pagination" aria-label="추천 곡 페이지">
<button class="page-arrow" type="button" aria-label="이전" disabled={pageNumber === 1} on:click={() => changePage(pageNumber - 1)}>‹</button>
{#each Array(pageCount) as _, index}<button type="button" aria-label={`${index + 1}페이지`} aria-current={pageNumber === index + 1 ? 'page' : undefined} on:click={() => changePage(index + 1)}>{index + 1}</button>{/each}
<button class="page-arrow" type="button" aria-label="다음" disabled={pageNumber === pageCount} on:click={() => changePage(pageNumber + 1)}>›</button>
</nav>
</div>
{:else if !running && !message}
<div class="empty-state">
<div class="empty-chart" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span></div>
<h3>{targets.length ? '이 편성의 추천곡을 확인하세요' : '분석할 가창곡이 없습니다'}</h3>
<p>{targets.length ? '곡마다 최적 배치를 계산해 레이팅이 높은 순서로 보여드립니다.' : '선택한 리더에 해당하는 공개된 가창 채보가 없습니다.'}</p>
{#if targets.length}<span class="empty-caption">예상 레이팅 · 최적 배치 · 상위 3곡 합계</span>{/if}
</div>
{/if}
<details class="calculation-guide">
<summary><span class="info-mark" aria-hidden="true">i</span>계산 기준 및 안내<span class="guide-chevron" aria-hidden="true">⌄</span></summary>
<div>
<p>멤버 5명과 리더·의상·성장 설정을 반영하고, 곡마다 스페셜 순서와 허용된 빈도 감소를 최적화합니다. 가창 보너스는 모든 곡에 10%를 적용합니다.</p>
<p>ALL PERFECT·스킬 최대 발동 기준의 예상 고점 순위입니다. 곡별 Easy·Normal·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>
</section>
<style>
.ranking { margin-top: 22px; padding: 28px; border: 1px solid #34313f; border-radius: 16px; background: #171b23; color: #e6e9f0; }
.heading, .section-heading, .search-bar, .actions, .progress-heading, .progress-caption, .list-heading, .list-footer, .pagination { display: flex; align-items: center; }
.heading { justify-content: space-between; gap: 16px; margin-bottom: 24px; }
.section-heading { gap: 13px; }
.section-number { display: grid; place-items: center; width: 38px; height: 38px; flex: none; border: 1px solid #624052; border-radius: 11px; background: #32232e; color: #efa4c6; font-size: 13px; font-weight: 850; }
h2 { margin: 0; font-size: 19px; letter-spacing: -.035em; }
.heading p { margin: 6px 0 0; color: #909aab; font-size: 13px; }
.search-bar { gap: 24px; padding: 20px 22px; border: 1px solid #34323f; border-radius: 12px; background: linear-gradient(110deg, #25212e, #1d222c 75%); }
.leader-summary { display: grid; gap: 7px; min-width: 120px; }
.eyebrow { color: #a69ead; font-size: 12px; font-weight: 650; }
.leader-summary > strong { font-size: 19px; letter-spacing: -.025em; }
.conditions { display: flex; gap: clamp(20px, 3vw, 44px); padding-left: 24px; border-left: 1px solid #3c3644; }
.conditions > div { display: grid; gap: 7px; }
.conditions > div > span { color: #929aab; font-size: 11px; white-space: nowrap; }
.conditions strong { font-size: 21px; font-variant-numeric: tabular-nums; }
.conditions small { margin-left: 3px; color: #9da7b6; font-size: 12px; font-weight: 500; }
.conditions .bonus, .conditions .bonus small { color: #eba2c2; }
.actions { gap: 9px; margin-left: auto; flex-wrap: wrap; justify-content: flex-end; }
button { border: 1px solid #3b4452; border-radius: 8px; background: #222936; color: #cad3e1; font: inherit; font-size: 12px; cursor: pointer; transition: background .15s, border-color .15s; }
button:hover:not(:disabled) { background: #303344; border-color: #7d526b; color: #f4c0d8; }
button:disabled { opacity: .4; cursor: default; }
button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px solid #f1a0c4; outline-offset: 4px; }
.primary { display: flex; justify-content: space-between; align-items: center; gap: 24px; min-height: 45px; padding: 11px 16px; border-color: #eda0c2; background: linear-gradient(110deg, #f2abc9, #df8db4); color: #291822; font-size: 13px; font-weight: 850; box-shadow: 0 4px 18px #dc7fad15; }
.primary > span:last-child { font-size: 20px; font-weight: 500; }
.primary:hover:not(:disabled) { background: #f5b6d2; color: #291822; border-color: #f5b6d2; }
.cancel { padding: 10px 12px; }
.compute-settings { margin: 15px 2px 0; }
.compute-option { position: relative; display: flex; align-items: center; gap: 9px; width: fit-content; flex-wrap: wrap; cursor: pointer; }
.compute-option input { position: absolute; z-index: 1; top: 0; left: 0; width: 30px; height: 18px; margin: 0; opacity: 0; cursor: pointer; }
.compute-switch { position: relative; width: 30px; height: 18px; border: 1px solid #555064; border-radius: 20px; background: #2a2935; }
.compute-switch::after { content: ''; position: absolute; top: 3px; left: 3px; width: 10px; height: 10px; border-radius: 50%; background: #9690a2; }
.compute-option input:checked + .compute-switch { border-color: #df94b7; background: #a66a8b; }
.compute-option input:checked + .compute-switch::after { left: 15px; background: #ffe0ee; }
.compute-option input:focus-visible + .compute-switch { outline: 2px solid #f1a0c4; outline-offset: 4px; }
.compute-option input:disabled ~ span, .compute-option input:disabled ~ strong { opacity: .5; cursor: default; }
.compute-option strong { color: #d2c6d6; font-size: 12px; font-weight: 650; }
.compute-count { color: #bc8aa8; font-size: 10px; }
.compute-settings p { margin: 7px 0 0 39px; color: #9b8c9e; font-size: 11px; line-height: 1.65; }
.progress-panel { margin-top: 20px; padding: 18px; border: 1px solid #423244; border-radius: 10px; background: #221e2a; }
.progress-heading { justify-content: space-between; gap: 12px; font-size: 13px; }
.progress-heading > span { display: flex; align-items: center; gap: 8px; }
.progress-heading strong { color: #eea2c5; font-size: 23px; font-variant-numeric: tabular-nums; }
.progress-heading small { font-size: 12px; }
progress { width: 100%; height: 6px; margin: 12px 0; border: none; border-radius: 9px; appearance: none; background: #3b2d3e; accent-color: #ec9bbe; overflow: hidden; }
progress::-webkit-progress-bar { background: #3b2d3e; }
progress::-webkit-progress-value { background: linear-gradient(90deg, #b87dab, #f0a7c9); border-radius: 9px; }
progress::-moz-progress-bar { background: #f0a7c9; }
.progress-caption { justify-content: space-between; gap: 16px; color: #a99ab2; font-size: 11px; }
.progress-caption > span:last-child { flex: none; }
.status-message { display: flex; align-items: center; gap: 8px; margin: 16px 2px 0; color: #9eafbb; font-size: 12px; line-height: 1.6; }
.status-dot { display: inline-block; flex: none; width: 5px; height: 5px; border-radius: 50%; background: #bf92b3; }
.warning { padding: 12px 16px; border: 1px solid #684d31; border-radius: 8px; background: #2c241c; color: #efbb7e; font-size: 13px; line-height: 1.6; }
.failure-details { margin-top: 16px; color: #e5b184; font-size: 12px; }
.rating-summary { display: grid; grid-template-columns: minmax(220px, .85fr) minmax(0, 1.4fr); gap: 32px; margin: 22px 0 28px; padding: 26px; border: 1px solid #53364a; border-radius: 13px; background: radial-gradient(ellipse at 0% 10%, #5c324c40, transparent 65%), #211d29; }
.total { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; gap: 9px; }
.rating-value { display: flex; align-items: baseline; gap: 11px; }
.rating-value > strong { color: #f5b5d1; font-size: clamp(38px, 4vw, 52px); line-height: 1.1; font-weight: 850; letter-spacing: -.05em; font-variant-numeric: tabular-nums; }
.rating-value > span { color: #a77995; font-size: 10px; font-weight: 800; letter-spacing: .12em; }
.total-caption { color: #a794aa; font-size: 11px; }
.contributors { padding-left: 28px; border-left: 1px solid #493244; min-width: 0; }
.contributors-title { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 13px; color: #d3b7cc; font-size: 12px; font-weight: 700; }
.contributors-title small { color: #937f97; font-size: 10px; font-weight: 400; }
.contributors ol { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.contributors li { display: flex; align-items: center; gap: 10px; min-width: 0; }
.contributor-index { color: #99768e; font-size: 10px; font-weight: 800; }
.contributors li > div { display: grid; flex: 1; min-width: 0; gap: 3px; }
.contributor-title { color: #d9d1df; font-size: 12px; overflow-wrap: anywhere; }
.contributors li small { color: #948699; font-size: 10px; }
.contributors li > strong { color: #eeb0ce; font-size: 16px; font-variant-numeric: tabular-nums; }
.list-heading { justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.list-heading h3 { margin: 0; font-size: 15px; }
.list-heading h3 > span { margin-left: 5px; color: #e4a0bf; font-size: 12px; }
.list-heading p { margin: 0; color: #9099aa; font-size: 11px; }
.list-heading p > span { margin: 0 5px; color: #525967; }
.table-scroll { overflow: auto; max-height: 570px; scroll-margin-top: 84px; border: 1px solid #313744; border-radius: 11px; scrollbar-width: thin; scrollbar-color: #655062 #1b2029; }
table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: right; font-variant-numeric: tabular-nums; }
th { position: sticky; top: 0; z-index: 1; padding: 12px 16px; background: #202630; color: #949eae; font-size: 11px; font-weight: 600; white-space: nowrap; }
th:first-child { width: 62px; text-align: center; }
th:nth-child(2) { text-align: left; }
td { padding: 17px 16px; border-top: 1px solid #303541; white-space: nowrap; background: #191f28; }
.top td { background: #241f2c; }
.top td:first-child { box-shadow: inset 2px 0 #ca87ae; }
.rank-cell { text-align: center; }
.rank-number { display: inline-grid; place-items: center; width: 29px; height: 29px; color: #8b95a6; font-size: 12px; font-weight: 750; }
.rank-number.first { background: #e9a1c1; border-radius: 8px; color: #33212c; }
.song { min-width: 190px; max-width: 420px; text-align: left; white-space: normal; }
.song > strong { display: block; color: #e3e5ed; font-size: 13px; font-weight: 650; line-height: 1.5; overflow-wrap: anywhere; }
.song-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 7px; }
.difficulty { padding: 3px 6px; border-radius: 4px; background: #383021; color: #d8b477; font-size: 9px; line-height: 1.25; }
.difficulty.expert { background: #392c43; color: #c2a2e0; }
.difficulty b { margin-left: 4px; font-weight: 650; }
.counted { color: #c38faa; font-size: 9px; }
.estimated { color: #c5a078; font-size: 9px; }
.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; }
.list-footer { justify-content: space-between; gap: 12px; margin-top: 15px; }
.range-label { color: #c4bdcd; font-size: 11px; }
.range-label > span { color: #808a9c; }
.pagination { gap: 5px; }
.pagination button { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; background: transparent; border-color: transparent; font-size: 11px; }
.pagination .page-arrow { border-color: #343c49; font-size: 20px; }
.pagination [aria-current='page'] { background: #382735; border-color: #80556e; color: #f0a6c7; font-weight: 800; }
.empty-state { display: flex; flex-direction: column; align-items: center; padding: 38px 16px; margin-top: 20px; border: 1px dashed #383744; border-radius: 12px; background: radial-gradient(ellipse at center, #33263430, transparent 70%); text-align: center; }
.empty-chart { display: flex; align-items: flex-end; gap: 5px; height: 33px; margin-bottom: 20px; }
.empty-chart span { width: 7px; height: 12px; border-radius: 3px; background: #665267; }
.empty-chart span:nth-child(2) { height: 23px; background: #97708e; }
.empty-chart span:nth-child(3) { height: 33px; background: #d396b6; }
.empty-chart span:nth-child(4) { height: 26px; background: #a07896; }
.empty-chart span:nth-child(5) { height: 17px; background: #735a73; }
.empty-state h3 { margin: 0; color: #d5cfde; font-size: 15px; font-weight: 650; }
.empty-state p { margin: 9px 0 16px; color: #8d96a8; font-size: 12px; line-height: 1.7; }
.empty-caption { color: #af809f; font-size: 10px; letter-spacing: .025em; }
.calculation-guide { margin-top: 22px; border-top: 1px solid #30343f; color: #909bad; font-size: 12px; }
.calculation-guide summary { display: flex; align-items: center; gap: 8px; padding-top: 16px; list-style: none; cursor: pointer; }
.calculation-guide summary::-webkit-details-marker { display: none; }
.info-mark { display: inline-grid; place-items: center; width: 15px; height: 15px; border: 1px solid #707083; border-radius: 50%; font-size: 10px; font-style: italic; }
.guide-chevron { margin-left: auto; color: #b99bb0; }
.calculation-guide[open] .guide-chevron { transform: rotate(180deg); }
.calculation-guide > div { padding: 6px 0 0 23px; }
.calculation-guide p { margin: 10px 0 0; line-height: 1.8; }
a { color: #dba1c0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 1000px) {
.search-bar { flex-wrap: wrap; gap: 20px; }
.leader-summary { flex: 1; }
.actions { flex-basis: 100%; }
.primary { flex: 1; }
td, th { padding-left: 12px; padding-right: 12px; }
}
@media (max-width: 700px) {
.ranking { padding: 18px 14px; }
.heading { align-items: flex-start; gap: 8px; margin-bottom: 18px; }
.section-heading { gap: 9px; align-items: flex-start; }
.section-number { width: 30px; height: 30px; border-radius: 8px; font-size: 11px; }
h2 { font-size: 16px; }
.heading p { font-size: 11px; line-height: 1.6; }
.search-bar { padding: 16px; gap: 17px; }
.leader-summary { flex-basis: 100%; gap: 5px; }
.leader-summary > strong { font-size: 18px; }
.conditions { flex: 1; justify-content: space-between; gap: 18px; border-left: 0; padding-left: 0; }
.actions { margin: 0; }
.primary { min-height: 44px; }
.rating-summary { grid-template-columns: 1fr; gap: 21px; padding: 21px 18px; }
.rating-value > strong { font-size: 43px; }
.contributors { padding: 19px 0 0; border-left: 0; border-top: 1px solid #493244; }
.contributors-title small { font-size: 9px; }
.list-heading p { font-size: 10px; }
.list-heading p > span { margin: 0 2px; }
.table-scroll { max-height: 680px; background: #171b23; border: 0; border-radius: 0; }
table, tbody { display: block; }
thead { display: none; }
tbody { display: grid; gap: 9px; }
tbody tr { display: grid; grid-template-columns: 27px minmax(0, 1fr) auto; gap: 7px 9px; padding: 15px 12px; border: 1px solid #303541; border-radius: 10px; background: #191f28; }
tbody tr.top { background: #241f2c; border-color: #55394c; }
td, .top td { padding: 0; border: 0; background: transparent; }
.top td:first-child { box-shadow: none; }
.rank-cell { grid-column: 1; grid-row: 1 / 4; padding-top: 1px; }
.rank-number { width: 25px; height: 25px; font-size: 11px; }
.song { grid-column: 2; grid-row: 1; min-width: 0; max-width: none; }
.song > strong { font-size: 12px; }
.song-meta { gap: 5px; margin: 6px 0 4px; }
.chart-rating { grid-column: 3; grid-row: 1; min-width: 51px; }
.chart-rating .mobile-label { display: block; margin: 0 0 3px; font-size: 9px; }
.chart-rating strong { font-size: 24px; }
.maximum-score { grid-column: 2; grid-row: 2; text-align: left; font-size: 11px; }
.average-score { grid-column: 2; grid-row: 3; text-align: left; font-size: 11px; }
.mobile-label { display: inline-block; min-width: 48px; margin-right: 6px; color: #828e9f; font-size: 10px; }
.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; }
.range-label { font-size: 10px; }
.empty-state { padding: 30px 14px; }
.empty-state h3 { font-size: 14px; }
.empty-state p { font-size: 11px; }
.progress-panel { padding: 14px; }
.progress-heading { font-size: 11px; }
.progress-caption { align-items: flex-start; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) { button { transition: none; } }
</style>
+10
View File
@@ -0,0 +1,10 @@
export const difficulties = ['expert', 'hard', 'normal', 'easy'] as const;
export type DifficultyKey = typeof difficulties[number];
export function isDifficulty(value: unknown): value is DifficultyKey {
return typeof value === 'string' && difficulties.some((difficulty) => difficulty === value);
}
export function difficultyLabel(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { difficulties, isDifficulty } from '../data/difficulty.ts';
import { calculateExactScore, perfectNoteScoreFromPower } from './score.ts';
test('모든 악곡의 네 난이도 채보를 불러와 실제 점수를 계산한다', async () => {
const catalog = JSON.parse(await readFile(new URL('../data/exact-chart-catalog.json', import.meta.url), 'utf8'));
const bySong = new Map<string, Set<string>>();
for (const row of catalog.charts) {
assert.ok(isDifficulty(row.difficulty));
const seen = bySong.get(row.songId) ?? new Set<string>();
assert.ok(!seen.has(row.difficulty), `${row.songId}: 중복 난이도`);
seen.add(row.difficulty);
bySong.set(row.songId, seen);
const chart = JSON.parse(await readFile(new URL(`../../../static${row.path}`, import.meta.url), 'utf8'));
assert.equal(chart.songId, row.songId);
assert.equal(chart.difficulty, row.difficulty);
assert.equal(chart.notes.length, row.totalNotes);
assert.ok(chart.notes.length > 0);
// 튜토리얼용 짧은 곡은 스페셜 구간이 두 개뿐이다.
assert.equal(chart.specialStartSeconds.length, row.songId === 'm9999' ? 2 : 5);
assert.equal(chart.scoreCalibrationSource, 'formula-v1');
const score = calculateExactScore([], {}, chart, {
perfectNoteScore: perfectNoteScoreFromPower(100000, chart.scoreRatio)
});
assert.ok(Number.isFinite(score.expectedScore) && score.expectedScore > 0, `${row.songId}.${row.difficulty}`);
}
assert.ok(bySong.size > 0);
for (const [songId, actual] of bySong) assert.deepEqual(actual, new Set(difficulties), songId);
});
+7
View File
@@ -41,6 +41,13 @@ test('성장 상태를 공유하는 기록 백업을 손실 없이 읽고 잘못
const parsed = parseAnalysisArchive(JSON.stringify(archive)); const parsed = parseAnalysisArchive(JSON.stringify(archive));
assert.equal(parsed.records.length, 2); assert.equal(parsed.snapshots.length, 1); assert.equal(parsed.records.length, 2); assert.equal(parsed.snapshots.length, 1);
assert.deepEqual(parsed.snapshots[0].growth, growth); assert.deepEqual(parsed.snapshots[0].growth, growth);
for (const difficulty of ['easy', 'normal', 'hard', 'expert']) {
record.settings.difficulty = difficulty;
assert.equal(parseAnalysisArchive(JSON.stringify(archive)).records[0].settings.difficulty, difficulty);
}
record.settings.difficulty = 'invalid';
assert.throws(() => parseAnalysisArchive(JSON.stringify(archive)));
record.settings.difficulty = 'expert';
record.settings.awakenings[0] = 8; record.settings.awakenings[0] = 8;
assert.throws(() => parseAnalysisArchive(JSON.stringify(archive))); assert.throws(() => parseAnalysisArchive(JSON.stringify(archive)));
}); });
+22
View File
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderReleaseMarkdown } from '../release-notes/markdown.ts';
test('업데이트의 모든 문단과 목록을 하나의 카드에 묶는다', () => {
const html = renderReleaseMarkdown('## 날짜\n\n### 기능\n\n첫 문단\n\n추가 문단\n\n- 목록\n\n### 다음 기능\n\n설명');
const cards = [...html.matchAll(/<section class="update-entry">([\s\S]*?)<\/section>/g)];
assert.equal(cards.length, 2);
assert.match(cards[0][1], /첫 문단[\s\S]*추가 문단[\s\S]*<ul>/);
assert.doesNotMatch(cards[0][1], /다음 기능/);
assert.match(cards[1][1], /다음 기능[\s\S]*설명/);
assert.ok(html.indexOf('<h2>') < html.indexOf('<section'));
});
test('구분선과 날짜는 카드 밖에 두고 원시 HTML은 계속 이스케이프한다', () => {
const html = renderReleaseMarkdown('### 기능\n\n<script>alert(1)</script>\n\n---\n\n## 다음 날짜\n\n### 새 기능\n\n[문서](https://example.com)');
assert.match(html, /<\/section>\s*<hr>/);
assert.match(html, /<hr>\s*<h2>다음 날짜<\/h2>\s*<section/);
assert.doesNotMatch(html, /<script>/);
assert.match(html, /&lt;script&gt;/);
assert.match(html, /href="https:\/\/example.com\/"/);
});
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { leaderRating, leaderRatingCharts, rankSongCharts, rankSongs, songRating } from './song-rating.ts';
test('레이팅은 5000점 단위로 올림하며 잘못된 점수를 거부한다', () => {
assert.deepEqual([0, 1, 5000, 5001, 1234567].map(songRating), [0, 1, 1, 2, 247]);
for (const score of [-1, NaN, Infinity]) assert.throws(() => songRating(score));
});
test('리더 가창곡의 공개된 채보만 선택한다', () => {
const charts = [
{ songId: 'solo', title: 'Solo', difficulty: 'expert', singerCharacterIds: ['leader'] },
{ songId: 'solo', title: 'Solo', difficulty: 'hard', singerCharacterIds: ['leader'] },
{ songId: 'group', title: 'Group', difficulty: 'expert', singerCharacterIds: ['other', 'leader'], releaseTime: 200 },
{ songId: 'other', title: 'Other', difficulty: 'expert', singerCharacterIds: ['other'] },
{ songId: 'unknown', title: 'Unknown', difficulty: 'expert', singerCharacterIds: [] }
];
assert.deepEqual(leaderRatingCharts(charts, 'leader', 'expert', 199), [charts[0]]);
assert.deepEqual(leaderRatingCharts(charts, 'leader', 'both', 200), charts.slice(0, 3));
assert.deepEqual(leaderRatingCharts(charts, '', 'both', 200), []);
});
test('고점 순위와 상위 3곡 합계는 동일 곡의 난이도를 중복 집계하지 않는다', () => {
const row = (songId: string, score: number, difficulty = 'expert') => ({
songId, title: songId, difficulty, theoreticalMaxScore: score, expectedScore: score / 2
});
const results = [row('a', 5001), row('b', 10001), row('a', 20001, 'hard'), row('c', 9999), row('d', 5000)];
assert.deepEqual(rankSongCharts(results).map((item) => [item.songId, item.difficulty]), [['a', 'hard'], ['b', 'expert'], ['c', 'expert'], ['a', 'expert'], ['d', 'expert']]);
assert.deepEqual(rankSongs(results).map((item) => [item.songId, item.difficulty]), [['a', 'hard'], ['b', 'expert'], ['c', 'expert'], ['d', 'expert']]);
assert.equal(leaderRating(results), 10);
assert.equal(leaderRating([]), 0);
assert.equal(results[0].theoreticalMaxScore, 5001);
});
test('전체 난이도 추천은 Easy와 Normal을 포함하고 곡별 최고점만 합산한다', () => {
const charts = ['easy', 'normal', 'hard', 'expert'].map((difficulty, index) => ({
songId: 'song', title: 'Song', difficulty, singerCharacterIds: ['leader'],
theoreticalMaxScore: 20000 - index * 5000, expectedScore: 1000
}));
assert.deepEqual(leaderRatingCharts(charts, 'leader', 'all', Date.now()), charts);
assert.deepEqual(leaderRatingCharts(charts, 'leader', 'normal', Date.now()), [charts[1]]);
assert.equal(rankSongs(charts)[0].difficulty, 'easy');
assert.equal(leaderRating(charts), 4);
});
+51
View File
@@ -0,0 +1,51 @@
import { isDifficulty } from '../data/difficulty.ts';
import { releasedSongCharts } from '../data/song-release.ts';
export type RatingChart = {
songId: string;
title: string;
difficulty: string;
singerCharacterIds?: readonly string[];
releaseTime?: number | null;
};
// Source: https://dreams.wf-calc.net/wiki/live (2026-09-07).
export function songRating(score: number): number {
if (!Number.isFinite(score) || score < 0) throw new Error('레이팅 점수는 0 이상의 유한한 수여야 합니다.');
return Math.ceil(score / 5000);
}
export function leaderRatingCharts<T extends RatingChart>(charts: readonly T[], leaderId: string, difficulty: string, now: number): T[] {
return releasedSongCharts(charts, now).filter((chart) =>
Boolean(leaderId) && chart.singerCharacterIds?.includes(leaderId) &&
(difficulty === 'all' ? isDifficulty(chart.difficulty) : difficulty === 'both' ? chart.difficulty === 'hard' || chart.difficulty === 'expert' : chart.difficulty === difficulty));
}
export type SongRatingScore = {
songId: string;
title: string;
difficulty: string;
theoreticalMaxScore: number;
expectedScore: number;
};
export function rankSongCharts<T extends SongRatingScore>(results: readonly T[]): T[] {
return [...results].sort((a, b) =>
songRating(b.theoreticalMaxScore) - songRating(a.theoreticalMaxScore) ||
b.theoreticalMaxScore - a.theoreticalMaxScore || b.expectedScore - a.expectedScore ||
a.title.localeCompare(b.title, 'en') || a.songId.localeCompare(b.songId) || a.difficulty.localeCompare(b.difficulty));
}
export function rankSongs<T extends SongRatingScore>(results: readonly T[]): T[] {
const sorted = rankSongCharts(results);
const seen = new Set<string>();
return sorted.filter((row) => {
if (seen.has(row.songId)) return false;
seen.add(row.songId);
return true;
});
}
export function leaderRating(results: readonly SongRatingScore[]): number {
return rankSongs(results).slice(0, 3).reduce((sum, row) => sum + songRating(row.theoreticalMaxScore), 0);
}
+1 -1
View File
@@ -50,7 +50,7 @@ test('공식 적용 카탈로그와 실제 채보가 같은 분모 및 환산값
assert.equal(chart.notes.length, ref.fullComboNoteCount); assert.equal(chart.notes.length, ref.fullComboNoteCount);
formulaCount += 1; formulaCount += 1;
} }
assert.equal(formulaCount, 388); assert.equal(formulaCount, catalog.charts.length);
}); });
test('잘못된 분모와 난이도로 공식 환산값을 만들지 않는다', () => { test('잘못된 분모와 난이도로 공식 환산값을 만들지 않는다', () => {
+2 -1
View File
@@ -1,7 +1,8 @@
import type { DifficultyKey } from '../data/difficulty.ts';
import type { CooldownReduction, ExactOptimizationResult } from '../engine/types.ts'; import type { CooldownReduction, ExactOptimizationResult } from '../engine/types.ts';
export type AnalysisSettings = { export type AnalysisSettings = {
songId: string; difficulty: 'hard' | 'expert'; cardIds: string[]; awakenings: number[]; songId: string; difficulty: DifficultyKey; cardIds: string[]; awakenings: number[];
fixedCooldownReductions?: (CooldownReduction | null)[]; fixedCooldownReductions?: (CooldownReduction | null)[];
allBlueNodes?: boolean[]; generationBonusOverrides?: (number | null)[]; allBlueNodes?: boolean[]; generationBonusOverrides?: (number | null)[];
songScoreBonusOverride?: number | null; leaderCharacterId: string; leaderOutfitCardId: string; songScoreBonusOverride?: number | null; leaderCharacterId: string; leaderOutfitCardId: string;
+18 -2
View File
@@ -39,10 +39,26 @@ renderer.image = ({ href, title, text }) => {
}; };
export function renderReleaseMarkdown(markdown: string): string { export function renderReleaseMarkdown(markdown: string): string {
return marked.parse(markdown, { const options = {
async: false, async: false,
breaks: true, breaks: true,
gfm: true, gfm: true,
renderer renderer
}) as string; } as const;
const tokens = marked.lexer(markdown, options);
let html = '';
let entryOpen = false;
for (const token of tokens) {
const boundary = token.type === 'hr' || (token.type === 'heading' && token.depth <= 3);
if (boundary && entryOpen) {
html += '</section>\n';
entryOpen = false;
}
if (token.type === 'heading' && token.depth === 3) {
html += '<section class="update-entry">\n';
entryOpen = true;
}
html += marked.parser([token], options);
}
return html + (entryOpen ? '</section>\n' : '');
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { isDifficulty } from '../data/difficulty.ts';
import { compactResult, snapshotKey, type AnalysisCapture, type AnalysisRecord, type GrowthSnapshot } from '../models/analysis-history.ts'; import { compactResult, snapshotKey, type AnalysisCapture, type AnalysisRecord, type GrowthSnapshot } from '../models/analysis-history.ts';
import type { ExactOptimizationResult } from '../engine/types.ts'; import type { ExactOptimizationResult } from '../engine/types.ts';
@@ -24,7 +25,7 @@ export function parseAnalysisArchive(text: string): Archive {
if (![record.id, record.name, record.title, record.analyzedAt].every((s) => typeof s === 'string') || !Number.isFinite(Date.parse(record.analyzedAt))) return invalid(); if (![record.id, record.name, record.title, record.analyzedAt].every((s) => typeof s === 'string') || !Number.isFinite(Date.parse(record.analyzedAt))) return invalid();
const s = record.settings, r = record.result; const s = record.settings, r = record.result;
if (!strings(s.cardIds) || s.cardIds.length !== 5 || new Set(s.cardIds).size !== 5 || !Array.isArray(s.awakenings) || s.awakenings.length !== 5 || !s.awakenings.every((n) => Number.isInteger(n) && n >= 0 && n <= 5)) return invalid(); if (!strings(s.cardIds) || s.cardIds.length !== 5 || new Set(s.cardIds).size !== 5 || !Array.isArray(s.awakenings) || s.awakenings.length !== 5 || !s.awakenings.every((n) => Number.isInteger(n) && n >= 0 && n <= 5)) return invalid();
if (typeof s.songId !== 'string' || !['hard', 'expert'].includes(s.difficulty) || typeof s.leaderCharacterId !== 'string' || typeof s.leaderOutfitCardId !== 'string') return invalid(); if (typeof s.songId !== 'string' || !isDifficulty(s.difficulty) || typeof s.leaderCharacterId !== 'string' || typeof s.leaderOutfitCardId !== 'string') return invalid();
if (s.allBlueNodes && (s.allBlueNodes.length !== 5 || !s.allBlueNodes.every((v) => typeof v === 'boolean'))) return invalid(); if (s.allBlueNodes && (s.allBlueNodes.length !== 5 || !s.allBlueNodes.every((v) => typeof v === 'boolean'))) return invalid();
if (s.fixedCooldownReductions && (s.fixedCooldownReductions.length !== 5 || !s.fixedCooldownReductions.every((v) => v === null || [0, 4, 8, 12].includes(v)))) return invalid(); if (s.fixedCooldownReductions && (s.fixedCooldownReductions.length !== 5 || !s.fixedCooldownReductions.every((v) => v === null || [0, 4, 8, 12].includes(v)))) return invalid();
if (s.generationBonusOverrides && (s.generationBonusOverrides.length !== 5 || !s.generationBonusOverrides.every((v) => v === null || (finite(v) && v <= 900)))) return invalid(); if (s.generationBonusOverrides && (s.generationBonusOverrides.length !== 5 || !s.generationBonusOverrides.every((v) => v === null || (finite(v) && v <= 900)))) return invalid();
@@ -79,6 +79,7 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options); const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
let achievementProbabilities: ScoreAchievementProbabilities | undefined; let achievementProbabilities: ScoreAchievementProbabilities | undefined;
if (event.data.includeAchievementProbabilities) { if (event.data.includeAchievementProbabilities) {
self.postMessage({ type: 'probability-start' });
const orderedCards = cardsForResult(event.data.cards, result); const orderedCards = cardsForResult(event.data.cards, result);
achievementProbabilities = estimateScoreAchievementProbabilities( achievementProbabilities = estimateScoreAchievementProbabilities(
orderedCards, orderedCards,
+1
View File
@@ -32,6 +32,7 @@
<nav aria-label="주요 메뉴"> <nav aria-label="주요 메뉴">
<a href="/cards" aria-current={$page.url.pathname.startsWith('/cards') ? 'page' : undefined}>카드 목록</a> <a href="/cards" aria-current={$page.url.pathname.startsWith('/cards') ? 'page' : undefined}>카드 목록</a>
<a href="/analysis" aria-current={$page.url.pathname.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a> <a href="/analysis" aria-current={$page.url.pathname.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
<a href="/rating" aria-current={$page.url.pathname.startsWith('/rating') ? 'page' : undefined}>리더 레이팅</a>
<a href="/combination" aria-current={$page.url.pathname.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a> <a href="/combination" aria-current={$page.url.pathname.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
{#if showEventTab}<a href="/event" aria-current={$page.url.pathname.startsWith('/event') ? 'page' : undefined}>이벤트 Pt</a>{/if} {#if showEventTab}<a href="/event" aria-current={$page.url.pathname.startsWith('/event') ? 'page' : undefined}>이벤트 Pt</a>{/if}
<a href="/data" aria-current={$page.url.pathname.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a> <a href="/data" aria-current={$page.url.pathname.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
+7 -2
View File
@@ -31,13 +31,18 @@
<div><h3>조합 탐색</h3><p>리더와 악곡, 고정 멤버를 기준으로 남은 자리에 들어갈 최적의 카드를 비교합니다.</p></div> <div><h3>조합 탐색</h3><p>리더와 악곡, 고정 멤버를 기준으로 남은 자리에 들어갈 최적의 카드를 비교합니다.</p></div>
<span class="arrow" aria-hidden="true">→</span> <span class="arrow" aria-hidden="true">→</span>
</a> </a>
<a class="feature-card" href="/data/boards"> <a class="feature-card" href="/rating">
<span class="feature-number">03</span> <span class="feature-number">03</span>
<div><h3>리더 레이팅</h3><p>입력한 편성으로 레이팅을 높이기 좋은 가창곡의 모든 난이도를 최대 20개 추천하고, 5개씩 비교합니다.</p></div>
<span class="arrow" aria-hidden="true">→</span>
</a>
<a class="feature-card" href="/data/boards">
<span class="feature-number">04</span>
<div><h3>멤버 보드</h3><p>멤버별 노드와 연결 카드를 설정하고 초록 노드의 자원 대비 효율을 분석합니다.</p></div> <div><h3>멤버 보드</h3><p>멤버별 노드와 연결 카드를 설정하고 초록 노드의 자원 대비 효율을 분석합니다.</p></div>
<span class="arrow" aria-hidden="true">→</span> <span class="arrow" aria-hidden="true">→</span>
</a> </a>
<a class="feature-card" href="/cards"> <a class="feature-card" href="/cards">
<span class="feature-number">04</span> <span class="feature-number">05</span>
<div><h3>카드 목록</h3><p>카드의 타입과 주스탯, 패시브·액티브·스페셜 스킬 정보를 빠르게 찾아봅니다.</p></div> <div><h3>카드 목록</h3><p>카드의 타입과 주스탯, 패시브·액티브·스페셜 스킬 정보를 빠르게 찾아봅니다.</p></div>
<span class="arrow" aria-hidden="true">→</span> <span class="arrow" aria-hidden="true">→</span>
</a> </a>
+74 -16
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, tick } from 'svelte';
import LeaderSongRanking from '$lib/components/LeaderSongRanking.svelte';
import { analytics } from '$lib/analytics.ts'; import { analytics } from '$lib/analytics.ts';
import AnalysisHistory from '$lib/components/AnalysisHistory.svelte'; import AnalysisHistory from '$lib/components/AnalysisHistory.svelte';
import type { AnalysisCapture, AnalysisSettings, GrowthSnapshot } from '$lib/models/analysis-history.ts'; import type { AnalysisCapture, AnalysisSettings, GrowthSnapshot } from '$lib/models/analysis-history.ts';
@@ -21,8 +22,10 @@
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage'; import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts'; import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
export let mode: 'analysis' | 'rating' = 'analysis';
type CardRow = (typeof cardSummary.cards)[number]; type CardRow = (typeof cardSummary.cards)[number];
type DifficultyKey = 'hard' | 'expert'; import { difficulties, difficultyLabel, isDifficulty, type DifficultyKey } from '$lib/data/difficulty.ts';
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>; type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
type MemberBoards = Record<string, string[]>; type MemberBoards = Record<string, string[]>;
type BoardCards = Record<string, Record<string, string>>; type BoardCards = Record<string, Record<string, string>>;
@@ -56,7 +59,7 @@
leaderOutfitCardId: string; leaderOutfitCardId: string;
}; };
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2'; const ANALYSIS_STORAGE_KEY = mode === 'rating' ? 'holodori.rating.v1' : 'holodori.analysis.v2';
const ANALYSIS_PRESET_STORAGE_KEY = 'holodori.analysis.presets.v1'; const ANALYSIS_PRESET_STORAGE_KEY = 'holodori.analysis.presets.v1';
const LEGACY_ANALYSIS_STORAGE_KEY = 'holodori.analysis.v1'; const LEGACY_ANALYSIS_STORAGE_KEY = 'holodori.analysis.v1';
const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const; const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const;
@@ -104,12 +107,14 @@
let allBlueNodes = selectedCardIds.map(() => false); let allBlueNodes = selectedCardIds.map(() => false);
let generationBonusOverrides: (number | null)[] = selectedCardIds.map(() => null); let generationBonusOverrides: (number | null)[] = selectedCardIds.map(() => null);
let songScoreBonusOverride: number | null = null; let songScoreBonusOverride: number | null = null;
$: if (mode === 'rating' && songScoreBonusOverride !== 10) songScoreBonusOverride = 10;
let leaderCharacterId = leaderMembers[0]?.characterId ?? ''; let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
let leaderOutfitCardId = leaderMembers[0]?.id ?? ''; let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
let worker: Worker | null = null; let worker: Worker | null = null;
let running = false; let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 }; let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let result: ExactOptimizationResult | null = null; let result: ExactOptimizationResult | null = null;
let ratingResultKey = '';
let resultCapture: AnalysisCapture | null = null; let resultCapture: AnalysisCapture | null = null;
let pendingCapture: AnalysisCapture | null = null; let pendingCapture: AnalysisCapture | null = null;
const comparisonWorkers = new Set<Worker>(); const comparisonWorkers = new Set<Worker>();
@@ -198,6 +203,15 @@
leaderCharacterId, leaderCharacterId,
leaderOutfitCardId leaderOutfitCardId
} satisfies SavedAnalysisState; } satisfies SavedAnalysisState;
$: ratingInputKey = JSON.stringify({
selectedCardIds, selectedAwakenings, fixedCooldownReductions, allBlueNodes,
generationBonusOverrides, songScoreBonusOverride, leaderCharacterId, leaderOutfitCardId,
collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent
});
$: if (mode === 'rating' && result && ratingResultKey !== ratingInputKey) {
result = null;
resultCapture = null;
}
$: if (analysisReady) saveAnalysisState({ $: if (analysisReady) saveAnalysisState({
schemaVersion: 2, schemaVersion: 2,
songId: selectedSongId, songId: selectedSongId,
@@ -251,7 +265,8 @@
} }
try { try {
const currentState = localStorage.getItem(ANALYSIS_STORAGE_KEY); const currentState = localStorage.getItem(ANALYSIS_STORAGE_KEY)
?? (mode === 'rating' ? localStorage.getItem('holodori.analysis.v2') : null);
const legacyState = currentState ? null : localStorage.getItem(LEGACY_ANALYSIS_STORAGE_KEY); const legacyState = currentState ? null : localStorage.getItem(LEGACY_ANALYSIS_STORAGE_KEY);
restoreAnalysisState(currentState ?? legacyState, Boolean(legacyState)); restoreAnalysisState(currentState ?? legacyState, Boolean(legacyState));
if (legacyState) localStorage.removeItem(LEGACY_ANALYSIS_STORAGE_KEY); if (legacyState) localStorage.removeItem(LEGACY_ANALYSIS_STORAGE_KEY);
@@ -318,7 +333,7 @@
const state = JSON.parse(stored) as Partial<SavedAnalysisState>; const state = JSON.parse(stored) as Partial<SavedAnalysisState>;
if (!legacy && state.schemaVersion !== 2) return; if (!legacy && state.schemaVersion !== 2) return;
if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId; if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
if (state.difficulty === 'hard' || state.difficulty === 'expert') difficulty = state.difficulty; if (isDifficulty(state.difficulty)) difficulty = state.difficulty;
songScoreBonusOverride = normalizeSongScoreBonusOverride(state.songScoreBonusOverride); songScoreBonusOverride = normalizeSongScoreBonusOverride(state.songScoreBonusOverride);
if (Array.isArray(state.cardIds) && state.cardIds.length === 5) { if (Array.isArray(state.cardIds) && state.cardIds.length === 5) {
@@ -467,13 +482,13 @@
}; };
} }
function connectionScoreContext(placements: BoardCards, chartSummary: NonNullable<typeof selectedChart>) { function connectionScoreContext(placements: BoardCards, chartSummary: NonNullable<typeof selectedChart>, songBonusOverride = songScoreBonusOverride) {
const localBoardEffects = calculateBoardEffects(boardSummary, effectiveMemberBoards, selectedSourceCards, leaderCharacterId, chartSummary, { const localBoardEffects = calculateBoardEffects(boardSummary, effectiveMemberBoards, selectedSourceCards, leaderCharacterId, chartSummary, {
placements, placements,
cards: connectionCards cards: connectionCards
}, { }, {
generationBonusByCardId, generationBonusByCardId,
...(songScoreBonusOverride === null ? {} : { directSongScoreBonusPercent: songScoreBonusOverride }) ...(songBonusOverride === null ? {} : { directSongScoreBonusPercent: songBonusOverride })
}); });
const localUnitPower = calculateUnitPower(selectedCardIds.map((id, index) => ({ const localUnitPower = calculateUnitPower(selectedCardIds.map((id, index) => ({
card: cardById.get(id)!, awakening: selectedAwakenings[index] card: cardById.get(id)!, awakening: selectedAwakenings[index]
@@ -497,6 +512,33 @@
return { cards, boardEffects: localBoardEffects, unitPower: localUnitPower }; return { cards, boardEffects: localBoardEffects, unitPower: localUnitPower };
} }
function ratingContext(chart: NonNullable<typeof selectedChart>) {
const context = connectionScoreContext(boardCards, chart, 10);
if (context.cards.length !== 5 || new Set(selectedSourceCards.map((card) => card.characterId)).size !== 5 ||
!Number.isFinite(context.unitPower.overallPower) || context.unitPower.overallPower <= 0) {
throw new Error('서로 다른 멤버 5명과 성장 설정을 확인해 주세요.');
}
return { cards: context.cards, overallPower: context.unitPower.overallPower,
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent };
}
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 = probabilities;
clearConnectionOptimizationProposal();
await tick();
resultCapture = structuredClone({
settings: presetSnapshot,
growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
title: chart.title, durationSeconds: chart.durationSeconds, overallPower: power,
analyzedAt: new Date().toISOString()
});
document.getElementById('result-title')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function connectionSocketName(memberId: string, socketId: string): string { function connectionSocketName(memberId: string, socketId: string): string {
const member = boardMembers.find((item) => item.id === memberId); const member = boardMembers.find((item) => item.id === memberId);
const socket = member const socket = member
@@ -853,14 +895,14 @@
} }
</script> </script>
<svelte:head><title>배치 분석 · 홀로도리 계산기</title></svelte:head> <svelte:head><title>{mode === 'rating' ? '리더 레이팅' : '배치 분석'} · 홀로도리 계산기</title></svelte:head>
<main> <main>
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>배치 분석</h1> <h1>{mode === 'rating' ? '리더 레이팅' : '배치 분석'}</h1>
<p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.</p> <p class="page-description">{mode === 'rating' ? '입력한 편성으로 리더 가창곡의 모든 난이도를 예상 레이팅 순으로 최대 20개 추천합니다. 결과는 5개씩 확인할 수 있습니다.' : '특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.'}</p>
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'} {#if mode === 'analysis' && selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p> <p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
{/if} {/if}
</div> </div>
@@ -873,11 +915,11 @@
</div> </div>
<div class="song-fields"> <div class="song-fields">
{#if mode === 'analysis'}
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div> <div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
<label>난이도 <label>난이도
<select bind:value={difficulty} on:change={() => result = null}> <select bind:value={difficulty} on:change={() => result = null}>
<option value="expert">Expert</option> {#each difficulties as value}<option {value}>{difficultyLabel(value)}</option>{/each}
<option value="hard">Hard</option>
</select> </select>
</label> </label>
<label>가창 보너스 덮어쓰기 (0~10%) <label>가창 보너스 덮어쓰기 (0~10%)
@@ -894,6 +936,9 @@
<span class="in-game-note-score">인게임 1노트 {inGamePerfectNoteScore.toLocaleString('ko-KR')}점</span> <span class="in-game-note-score">인게임 1노트 {inGamePerfectNoteScore.toLocaleString('ko-KR')}점</span>
</div> </div>
<p class="note-score-guide">인게임 1노트는 일반 PERFECT 기준으로 가창 보너스를 반영한 점수입니다. 스킬·콤보 보너스는 제외하며, 기본점수의 소수점을 유지해 보너스를 적용한 뒤 올림합니다.</p> <p class="note-score-guide">인게임 1노트는 일반 PERFECT 기준으로 가창 보너스를 반영한 점수입니다. 스킬·콤보 보너스는 제외하며, 기본점수의 소수점을 유지해 보너스를 적용한 뒤 올림합니다.</p>
{:else}
<p class="note-score-guide"><strong>가창 보너스 10% 고정</strong> · 모든 추천 대상 곡에 동일하게 적용합니다. 종합력과 스킬 효과는 각 곡의 가창 조건에 맞춰 계산합니다.</p>
{/if}
</div> </div>
<div class="card-fields"> <div class="card-fields">
@@ -951,7 +996,7 @@
</div> </div>
{/each} {/each}
</div> </div>
<p class="board-guide">‘파란 노드 전체’를 사용하지 않으면 사용자 데이터에 저장된 해당 멤버의 보드를 사용합니다. 기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 실제 보드 수치를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다.</p> <p class="board-guide">‘파란 노드 전체’를 사용하지 않으면 사용자 데이터에 저장된 해당 멤버의 보드를 사용합니다. {mode === 'rating' ? '기수 보너스는 빈칸이면 실제 보드 수치를 사용합니다. 가창 보너스는 모든 곡에 10%를 적용합니다.' : '기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 실제 보드 수치를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다.'}</p>
<div class="leader-fields"> <div class="leader-fields">
<label>리더 <label>리더
<select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}> <select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}>
@@ -968,19 +1013,29 @@
<span>리더 의상 효과</span> <span>리더 의상 효과</span>
<p>{leaderOutfitDescription || '적용할 수 있는 의상이 없습니다.'}</p> <p>{leaderOutfitDescription || '적용할 수 있는 의상이 없습니다.'}</p>
</div> </div>
<p class="power-detail">최대 레벨 기준 · 기본 {unitPower.baseParameterTotal.toLocaleString('ko-KR')} + 개화 {unitPower.potentialTotal.toLocaleString('ko-KR')} + 패시브 {unitPower.passiveTotal.toLocaleString('ko-KR')} + 의상 {unitPower.outfitTotal.toLocaleString('ko-KR')} + 보드 {unitPower.boardTotal.toLocaleString('ko-KR')} + 메모리 {unitPower.memoryTotal.toLocaleString('ko-KR')} ({memoryCount}개) + 멤버 강화 {unitPower.memberPowerUpTotal.toLocaleString('ko-KR')} ({memberPowerUpPercent.toLocaleString('ko-KR')}%)</p> {#if mode === 'analysis'}<p class="power-detail">최대 레벨 기준 · 기본 {unitPower.baseParameterTotal.toLocaleString('ko-KR')} + 개화 {unitPower.potentialTotal.toLocaleString('ko-KR')} + 패시브 {unitPower.passiveTotal.toLocaleString('ko-KR')} + 의상 {unitPower.outfitTotal.toLocaleString('ko-KR')} + 보드 {unitPower.boardTotal.toLocaleString('ko-KR')} + 메모리 {unitPower.memoryTotal.toLocaleString('ko-KR')} ({memoryCount}개) + 멤버 강화 {unitPower.memberPowerUpTotal.toLocaleString('ko-KR')} ({memberPowerUpPercent.toLocaleString('ko-KR')}%)</p>{/if}
<PresetManager storageKey={ANALYSIS_PRESET_STORAGE_KEY} snapshot={presetSnapshot} disabled={running} on:apply={applyPreset} /> <PresetManager storageKey={ANALYSIS_PRESET_STORAGE_KEY} snapshot={presetSnapshot} disabled={running} on:apply={applyPreset} />
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if} {#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
{#if mode === 'analysis'}
<button class="analyze" type="button" disabled={running || candidateCount === 0} on:click={analyze}> <button class="analyze" type="button" disabled={running || candidateCount === 0} on:click={analyze}>
{running ? `분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 배치 분석'} {running ? `분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 배치 분석'}
</button> </button>
{#if running}<progress value={progress.evaluatedCandidates} max={progress.totalCandidates}></progress>{/if} {#if running}<progress value={progress.evaluatedCandidates} max={progress.totalCandidates}></progress>{/if}
{/if}
</section> </section>
{#if mode === 'rating'}
<LeaderSongRanking {charts} leaderId={leaderCharacterId} leaderName={leaderMember?.name ?? ''}
now={releaseNow} inputKey={ratingInputKey} disabled={!analysisReady || running || connectionOptimizationRunning || candidateCount === 0}
getContext={ratingContext} onSelect={showRatingResult} />
{/if}
{#if mode === 'analysis' || result}
<section class="panel result" aria-labelledby="result-title"> <section class="panel result" aria-labelledby="result-title">
<div class="section-title"><div><span>02</span><h2 id="result-title">분석 결과</h2></div></div> <div class="section-title"><div><span>{mode === 'rating' ? '03' : '02'}</span><h2 id="result-title">분석 결과</h2></div></div>
{#if mode === 'rating' && result}<p class="board-guide"><strong>{selectedChart?.title} · {difficultyLabel(difficulty)}</strong> · 종합력 {overallPower.toLocaleString('ko-KR')} · 가창 보너스 10%</p>{/if}
{#if result} {#if result}
<div class="order-sections"> <div class="order-sections">
<section class="order-section"> <section class="order-section">
@@ -997,12 +1052,14 @@
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div> <div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div> <div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div>
</div> </div>
{#if mode === 'analysis' || achievementProbabilities}
<div class="probability-grid"> <div class="probability-grid">
<div><span>최고점 100%</span><strong>{probabilityLabel(achievementProbabilities?.maximum)}</strong></div> <div><span>최고점 100%</span><strong>{probabilityLabel(achievementProbabilities?.maximum)}</strong></div>
<div><span>최고점의 99% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast99Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.99)}점 이상</small></div> <div><span>최고점의 99% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast99Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.99)}점 이상</small></div>
<div><span>최고점의 95% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast95Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.95)}점 이상</small></div> <div><span>최고점의 95% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast95Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.95)}점 이상</small></div>
</div> </div>
<p class="probability-guide">모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p> <p class="probability-guide">모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
{/if}
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title"> <section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
<div class="connection-optimizer-heading"> <div class="connection-optimizer-heading">
@@ -1112,6 +1169,7 @@
{/if} {/if}
</section> </section>
<AnalysisHistory capture={resultCapture} {result} cardName={resultCardName} currentGrowth={latestGrowth} evaluate={evaluateHistory} /> <AnalysisHistory capture={resultCapture} {result} cardName={resultCardName} currentGrowth={latestGrowth} evaluate={evaluateHistory} />
{/if}
</main> </main>
<style> <style>
+5 -5
View File
@@ -41,7 +41,7 @@
export let eventMode = false; export let eventMode = false;
type CardRow = (typeof cardSummary.cards)[number]; type CardRow = (typeof cardSummary.cards)[number];
type DifficultyKey = 'hard' | 'expert'; import { difficulties, difficultyLabel, isDifficulty, type DifficultyKey } from '$lib/data/difficulty.ts';
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>; type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
type MemberBoards = Record<string, string[]>; type MemberBoards = Record<string, string[]>;
type BoardCards = Record<string, Record<string, string>>; type BoardCards = Record<string, Record<string, string>>;
@@ -370,7 +370,7 @@
function applySavedState(state: Partial<SavedState>): void { function applySavedState(state: Partial<SavedState>): void {
if (state.schemaVersion !== 1) throw new Error('Unsupported preset'); if (state.schemaVersion !== 1) throw new Error('Unsupported preset');
if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId; if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
if (state.difficulty === 'hard' || state.difficulty === 'expert') difficulty = state.difficulty; if (isDifficulty(state.difficulty)) difficulty = state.difficulty;
fixedCardIds = Array.from({ length: 4 }, (_, index) => cardById.has(state.fixedCardIds?.[index] ?? '') ? state.fixedCardIds![index] : ''); fixedCardIds = Array.from({ length: 4 }, (_, index) => cardById.has(state.fixedCardIds?.[index] ?? '') ? state.fixedCardIds![index] : '');
candidateAwakening = normalizeAwakening(state.awakening); candidateAwakening = normalizeAwakening(state.awakening);
candidateGenerationBonus = typeof state.candidateGenerationBonus === 'number' && Number.isFinite(state.candidateGenerationBonus) candidateGenerationBonus = typeof state.candidateGenerationBonus === 'number' && Number.isFinite(state.candidateGenerationBonus)
@@ -422,7 +422,7 @@
return { return {
schemaVersion: 2, schemaVersion: 2,
songId: typeof state.songId === 'string' ? state.songId : '', songId: typeof state.songId === 'string' ? state.songId : '',
difficulty: state.difficulty === 'hard' ? 'hard' : 'expert', difficulty: isDifficulty(state.difficulty) ? state.difficulty : 'expert',
cardIds: cards.map((card) => card.id), cardIds: cards.map((card) => card.id),
awakenings: cards.map((_, index) => Number.isFinite(state.awakenings?.[index]) ? normalizeAwakening(state.awakenings?.[index]) : 0), awakenings: cards.map((_, index) => Number.isFinite(state.awakenings?.[index]) ? normalizeAwakening(state.awakenings?.[index]) : 0),
fixedCooldownReductions: cards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index])), fixedCooldownReductions: cards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index])),
@@ -1140,7 +1140,7 @@
<div><span>00</span><h2 id="baseline-title">기준 조합</h2></div> <div><span>00</span><h2 id="baseline-title">기준 조합</h2></div>
<strong>배치 분석 저장값</strong> <strong>배치 분석 저장값</strong>
</div> </div>
<p class="baseline-condition">{baselineChartMetadata?.title} · {baselineState.difficulty === 'expert' ? 'Expert' : 'Hard'}</p> <p class="baseline-condition">{baselineChartMetadata?.title} · {difficultyLabel(baselineState.difficulty)}</p>
<div class="baseline-members"> <div class="baseline-members">
{#each baselineResult?.formationOrder ?? baselineState.cardIds as cardId, slot} {#each baselineResult?.formationOrder ?? baselineState.cardIds as cardId, slot}
<div><span>{slot + 1}</span><strong>{baselineCardLabel(cardId)}</strong></div> <div><span>{slot + 1}</span><strong>{baselineCardLabel(cardId)}</strong></div>
@@ -1164,7 +1164,7 @@
<div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>{ownedCardsOnly ? (eventMode ? '보유 이벤트 후보 대상' : '보유 5성 대상') : (eventMode ? '5성 + 대상 멤버 전 레어리티' : '5성 전체 대상')}</strong></div> <div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>{ownedCardsOnly ? (eventMode ? '보유 이벤트 후보 대상' : '보유 5성 대상') : (eventMode ? '5성 + 대상 멤버 전 레어리티' : '5성 전체 대상')}</strong></div>
<div class="song-fields"> <div class="song-fields">
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div> <div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
<label>난이도<select bind:value={difficulty} on:change={() => result = null}><option value="expert">Expert</option><option value="hard">Hard</option></select></label> <label>난이도<select bind:value={difficulty} on:change={() => result = null}>{#each difficulties as value}<option {value}>{difficultyLabel(value)}</option>{/each}</select></label>
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>점수 계수 · {scoreCalibrationLabel}</span></div> <div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>점수 계수 · {scoreCalibrationLabel}</span></div>
</div> </div>
<div class="candidate-fields"> <div class="candidate-fields">
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import AnalysisPage from '../analysis/+page.svelte';
</script>
<AnalysisPage mode="rating" />
+7 -4
View File
@@ -42,8 +42,12 @@
:global(.markdown-body h2) { position: relative; margin: 0 0 14px; padding-top: 2px; color: #cad0d9; font-size: 15px; font-weight: 850; } :global(.markdown-body h2) { position: relative; margin: 0 0 14px; padding-top: 2px; color: #cad0d9; font-size: 15px; font-weight: 850; }
:global(.markdown-body h2:not(:first-child)) { margin-top: 38px; } :global(.markdown-body h2:not(:first-child)) { margin-top: 38px; }
:global(.markdown-body h2::before) { position: absolute; top: 6px; left: -34px; width: 13px; height: 13px; border: 3px solid #101319; border-radius: 50%; background: #ed8bb5; box-shadow: 0 0 0 1px rgb(237 139 181 / 45%); content: ''; } :global(.markdown-body h2::before) { position: absolute; top: 6px; left: -34px; width: 13px; height: 13px; border: 3px solid #101319; border-radius: 50%; background: #ed8bb5; box-shadow: 0 0 0 1px rgb(237 139 181 / 45%); content: ''; }
:global(.markdown-body h3) { margin: 10px 0 0; padding: 19px 20px 0; border: 1px solid #2b3441; border-bottom: 0; border-radius: 12px 12px 0 0; color: #edf0f4; background: #151a22; font-size: 17px; letter-spacing: -.02em; } :global(.markdown-body .update-entry) { min-width: 0; margin: 10px 0; padding: 19px 20px; border: 1px solid #2b3441; border-radius: 12px; background: #151a22; overflow-wrap: anywhere; }
:global(.markdown-body h3 + p) { margin: 0 0 10px; padding: 8px 20px 19px; border: 1px solid #2b3441; border-top: 0; border-radius: 0 0 12px 12px; color: #9ca6b4; background: #151a22; font-size: 14px; line-height: 1.65; } :global(.markdown-body h3) { margin: 0; color: #edf0f4; font-size: 17px; letter-spacing: -.02em; }
:global(.markdown-body .update-entry p) { margin: 8px 0 0; color: #9ca6b4; font-size: 14px; line-height: 1.65; }
:global(.markdown-body .update-entry > :last-child) { margin-bottom: 0; }
:global(.markdown-body img) { max-width: 100%; height: auto; }
:global(.markdown-body pre) { max-width: 100%; overflow-x: auto; }
:global(.markdown-body code) { margin-right: 7px; padding: 3px 7px; border: 1px solid rgb(237 139 181 / 32%); border-radius: 999px; color: #efa0c1; background: rgb(237 139 181 / 9%); font-family: inherit; font-size: 10px; font-weight: 900; } :global(.markdown-body code) { margin-right: 7px; padding: 3px 7px; border: 1px solid rgb(237 139 181 / 32%); border-radius: 999px; color: #efa0c1; background: rgb(237 139 181 / 9%); font-family: inherit; font-size: 10px; font-weight: 900; }
:global(.markdown-body hr) { height: 1px; margin: 38px 0; border: 0; background: #2c3440; } :global(.markdown-body hr) { height: 1px; margin: 38px 0; border: 0; background: #2c3440; }
:global(.markdown-body ul), :global(.markdown-body ol) { color: #9ca6b4; line-height: 1.7; } :global(.markdown-body ul), :global(.markdown-body ol) { color: #9ca6b4; line-height: 1.7; }
@@ -54,7 +58,6 @@
.delivery-note { margin-bottom: 30px; } .delivery-note { margin-bottom: 30px; }
.markdown-body { padding-left: 27px; } .markdown-body { padding-left: 27px; }
:global(.markdown-body h2::before) { left: -27px; } :global(.markdown-body h2::before) { left: -27px; }
:global(.markdown-body h3) { padding: 17px 17px 0; } :global(.markdown-body .update-entry) { padding: 17px; }
:global(.markdown-body h3 + p) { padding: 8px 17px 17px; }
} }
</style> </style>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More