feat: add leader rating song recommendations

This commit is contained in:
2026-09-07 13:55:55 +09:00
parent 297eb1863c
commit 907c11b0e6
9 changed files with 625 additions and 15 deletions
+9 -1
View File
@@ -1,9 +1,17 @@
---
updatedAt: 2026-09-07T00:16:12+09:00
updatedAt: 2026-09-07T13:44:23+09:00
---
## 2026년 9월 7일
### 리더 레이팅 곡 추천
`NEW` 상위 메뉴에 리더 레이팅 탭을 추가했습니다. 입력한 멤버 5명과 리더·의상·성장 설정으로 가창 대상 곡의 최적 배치를 계산해, 가창곡의 Hard·Expert를 각각 예상 고점 레이팅 순으로 최대 20개, 5개씩 확인할 수 있습니다. 가창 보너스는 모든 곡에 10%를 적용합니다. Hard·Expert 비교, 상위 3곡 합계, 계산 취소와 추천 곡의 상세 배치 확인을 지원합니다.
리더와 분석 조건, 합산 레이팅과 상위 3곡을 요약해 보여주며, 모바일에서는 추천곡을 카드 형태로 확인할 수 있습니다. 계산 기준은 펼쳐서 확인할 수 있습니다.
‘최대 컴퓨팅 자원 활용’을 켜면 기기의 논리 코어 수에 맞춰 여러 채보를 동시에 계산합니다. 동시 계산 수와 전체 진행률을 표시하며, 취소하거나 입력 조건을 바꾸면 모든 계산을 중단합니다.
### 배치 분석의 인게임 1노트 점수 표시
`NEW` 배치 분석에서 기본 1노트 점수와 가창 보너스를 합산한 인게임 1노트 점수를 함께 확인할 수 있습니다. 일반 PERFECT·스킬/콤보 보너스 제외 기준이며, 가창 보너스 덮어쓰기 값도 즉시 반영합니다.
+3
View File
@@ -4,6 +4,7 @@
import HomePage from '../src/routes/+page.svelte';
import CardsPage from '../src/routes/cards/+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 EventPage from '../src/routes/event/+page.svelte';
import DataPage from '../src/routes/data/+page.svelte';
@@ -18,6 +19,7 @@
['/', HomePage],
['/cards', CardsPage],
['/analysis', AnalysisPage],
['/rating', RatingPage],
['/combination', CombinationPage],
['/event', EventPage],
['/data', DataPage],
@@ -78,6 +80,7 @@
<nav aria-label="주요 메뉴">
<a href="#/cards" aria-current={currentPath.startsWith('/cards') ? '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>
{#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>
+446
View File
@@ -0,0 +1,446 @@
<script lang="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 } from '$lib/engine/types.ts';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
type Chart = (typeof catalog.charts)[number];
type Context = { cards: OptimizerCard[]; overallPower: number; directSongScoreBonusPercent: number };
type Row = Chart & { result: ExactOptimizationResult; overallPower: number; theoreticalMaxScore: number; expectedScore: number };
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) => void;
const difficulty = 'both';
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} · ${summary.difficulty === 'expert' ? 'Expert' : 'Hard'}`;
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 = await new Promise<ExactOptimizationResult>((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: Math.min(1, Math.max(0, progress.evaluatedCandidates / Math.max(1, progress.totalCandidates))) } };
} else if (event.data.type === 'result') resolve(event.data.result);
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
});
});
if (id !== runId) return;
rows = [...rows, { ...summary, result, 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 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>{row.difficulty === 'expert' ? 'Expert' : 'Hard'} · 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"><span class="sr-only">상세 배치</span></th></tr></thead>
<tbody>{#each pageRows as row, index}<tr class:top={topSongs.includes(row)}>
<td class="rank-cell"><span class="rank-number" class:first={(pageNumber - 1) * pageSize + index === 0}>{(pageNumber - 1) * pageSize + index + 1}</span></td>
<td class="song"><strong>{row.title}</strong><div class="song-meta"><span class="difficulty" class:expert={row.difficulty === 'expert'}>{row.difficulty === 'expert' ? 'Expert' : 'Hard'} <b>{row.difficultyLevel}</b></span>{#if topSongs.includes(row)}<span class="counted">합산 대상</span>{/if}{#if row.scoreCalibrationSource !== 'formula-v1' && row.scoreCalibrationSource !== 'measured'}<span class="estimated">점수 계수 추정</span>{/if}</div></td>
<td class="chart-rating"><span class="mobile-label">레이팅</span><strong>{format(songRating(row.theoreticalMaxScore))}</strong></td>
<td class="maximum-score"><span class="mobile-label">예상 고점</span>{format(row.theoreticalMaxScore)}</td>
<td class="average-score"><span class="mobile-label">배치 평균</span>{format(row.expectedScore)}</td>
<td class="row-action"><button type="button" disabled={stale || disabled || running} on:click={() => onSelect(row, row.result, row.overallPower)}>배치 보기 <span aria-hidden="true">→</span></button></td>
</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·스킬 최대 발동 기준의 예상 고점 순위입니다. 곡별 Hard·Expert를 각각 표시하며, 최대 20개 결과를 5개씩 확인할 수 있습니다. 상위 3곡 합계는 같은 곡에서 고점이 높은 결과 하나만 집계합니다.</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; }
.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; }
.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>
+33
View File
@@ -0,0 +1,33 @@
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);
});
+50
View File
@@ -0,0 +1,50 @@
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 === '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
View File
@@ -32,6 +32,7 @@
<nav aria-label="주요 메뉴">
<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="/rating" aria-current={$page.url.pathname.startsWith('/rating') ? '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}
<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>
<span class="arrow" aria-hidden="true">→</span>
</a>
<a class="feature-card" href="/data/boards">
<a class="feature-card" href="/rating">
<span class="feature-number">03</span>
<div><h3>리더 레이팅</h3><p>입력한 편성으로 레이팅을 높이기 좋은 가창곡의 Hard·Expert를 최대 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>
<span class="arrow" aria-hidden="true">→</span>
</a>
<a class="feature-card" href="/cards">
<span class="feature-number">04</span>
<span class="feature-number">05</span>
<div><h3>카드 목록</h3><p>카드의 타입과 주스탯, 패시브·액티브·스페셜 스킬 정보를 빠르게 찾아봅니다.</p></div>
<span class="arrow" aria-hidden="true">→</span>
</a>
+71 -12
View File
@@ -1,5 +1,6 @@
<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 AnalysisHistory from '$lib/components/AnalysisHistory.svelte';
import type { AnalysisCapture, AnalysisSettings, GrowthSnapshot } from '$lib/models/analysis-history.ts';
@@ -21,6 +22,8 @@
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
export let mode: 'analysis' | 'rating' = 'analysis';
type CardRow = (typeof cardSummary.cards)[number];
type DifficultyKey = 'hard' | 'expert';
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
@@ -56,7 +59,7 @@
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 LEGACY_ANALYSIS_STORAGE_KEY = 'holodori.analysis.v1';
const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const;
@@ -104,12 +107,14 @@
let allBlueNodes = selectedCardIds.map(() => false);
let generationBonusOverrides: (number | null)[] = selectedCardIds.map(() => null);
let songScoreBonusOverride: number | null = null;
$: if (mode === 'rating' && songScoreBonusOverride !== 10) songScoreBonusOverride = 10;
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
let worker: Worker | null = null;
let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let result: ExactOptimizationResult | null = null;
let ratingResultKey = '';
let resultCapture: AnalysisCapture | null = null;
let pendingCapture: AnalysisCapture | null = null;
const comparisonWorkers = new Set<Worker>();
@@ -198,6 +203,15 @@
leaderCharacterId,
leaderOutfitCardId
} 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({
schemaVersion: 2,
songId: selectedSongId,
@@ -251,7 +265,8 @@
}
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);
restoreAnalysisState(currentState ?? legacyState, Boolean(legacyState));
if (legacyState) localStorage.removeItem(LEGACY_ANALYSIS_STORAGE_KEY);
@@ -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, {
placements,
cards: connectionCards
}, {
generationBonusByCardId,
...(songScoreBonusOverride === null ? {} : { directSongScoreBonusPercent: songScoreBonusOverride })
...(songBonusOverride === null ? {} : { directSongScoreBonusPercent: songBonusOverride })
});
const localUnitPower = calculateUnitPower(selectedCardIds.map((id, index) => ({
card: cardById.get(id)!, awakening: selectedAwakenings[index]
@@ -497,6 +512,33 @@
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) {
ratingResultKey = ratingInputKey;
selectedSongId = chart.songId;
difficulty = chart.difficulty as DifficultyKey;
result = rankingResult;
achievementProbabilities = undefined;
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 {
const member = boardMembers.find((item) => item.id === memberId);
const socket = member
@@ -853,14 +895,14 @@
}
</script>
<svelte:head><title>배치 분석 · 홀로도리 계산기</title></svelte:head>
<svelte:head><title>{mode === 'rating' ? '리더 레이팅' : '배치 분석'} · 홀로도리 계산기</title></svelte:head>
<main>
<header class="page-header">
<div>
<h1>배치 분석</h1>
<p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.</p>
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
<h1>{mode === 'rating' ? '리더 레이팅' : '배치 분석'}</h1>
<p class="page-description">{mode === 'rating' ? '입력한 편성으로 리더 가창곡의 Hard·Expert를 예상 레이팅 순으로 최대 20개 추천합니다. 결과는 5개씩 확인할 수 있습니다.' : '특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.'}</p>
{#if mode === 'analysis' && selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
{/if}
</div>
@@ -873,6 +915,7 @@
</div>
<div class="song-fields">
{#if mode === 'analysis'}
<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}>
@@ -894,6 +937,9 @@
<span class="in-game-note-score">인게임 1노트 {inGamePerfectNoteScore.toLocaleString('ko-KR')}점</span>
</div>
<p class="note-score-guide">인게임 1노트는 일반 PERFECT 기준으로 가창 보너스를 반영한 점수입니다. 스킬·콤보 보너스는 제외하며, 기본점수의 소수점을 유지해 보너스를 적용한 뒤 올림합니다.</p>
{:else}
<p class="note-score-guide"><strong>가창 보너스 10% 고정</strong> · 모든 추천 대상 곡에 동일하게 적용합니다. 종합력과 스킬 효과는 각 곡의 가창 조건에 맞춰 계산합니다.</p>
{/if}
</div>
<div class="card-fields">
@@ -951,7 +997,7 @@
</div>
{/each}
</div>
<p class="board-guide">‘파란 노드 전체’를 사용하지 않으면 사용자 데이터에 저장된 해당 멤버의 보드를 사용합니다. 기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 실제 보드 수치를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다.</p>
<p class="board-guide">‘파란 노드 전체’를 사용하지 않으면 사용자 데이터에 저장된 해당 멤버의 보드를 사용합니다. {mode === 'rating' ? '기수 보너스는 빈칸이면 실제 보드 수치를 사용합니다. 가창 보너스는 모든 곡에 10%를 적용합니다.' : '기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 실제 보드 수치를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다.'}</p>
<div class="leader-fields">
<label>리더
<select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}>
@@ -968,19 +1014,29 @@
<span>리더 의상 효과</span>
<p>{leaderOutfitDescription || '적용할 수 있는 의상이 없습니다.'}</p>
</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} />
{#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}>
{running ? `분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 배치 분석'}
</button>
{#if running}<progress value={progress.evaluatedCandidates} max={progress.totalCandidates}></progress>{/if}
{/if}
</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">
<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} · {difficulty === 'expert' ? 'Expert' : 'Hard'}</strong> · 종합력 {overallPower.toLocaleString('ko-KR')} · 가창 보너스 10%</p>{/if}
{#if result}
<div class="order-sections">
<section class="order-section">
@@ -997,12 +1053,14 @@
<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>
{#if mode === 'analysis' || achievementProbabilities}
<div class="probability-grid">
<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>최고점의 95% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast95Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.95)}점 이상</small></div>
</div>
<p class="probability-guide">모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
{/if}
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
<div class="connection-optimizer-heading">
@@ -1112,6 +1170,7 @@
{/if}
</section>
<AnalysisHistory capture={resultCapture} {result} cardName={resultCardName} currentGrowth={latestGrowth} evaluate={evaluateHistory} />
{/if}
</main>
<style>
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import AnalysisPage from '../analysis/+page.svelte';
</script>
<AnalysisPage mode="rating" />