feat: support every song difficulty including Easy and Normal
Publish GitHub Pages output / publish (push) Successful in 41s

This commit is contained in:
2026-09-07 14:18:10 +09:00
parent 8f97683271
commit 0af64052c6
405 changed files with 12211 additions and 25 deletions
+1 -1
View File
@@ -126,7 +126,7 @@
{#each records as record (record.id)}
<li class:selected={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>
<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>
+6 -5
View File
@@ -1,4 +1,5 @@
<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';
@@ -20,7 +21,7 @@
export let getContext: (chart: Chart) => Context;
export let onSelect: (chart: Chart, result: ExactOptimizationResult, overallPower: number, probabilities: ScoreAchievementProbabilities) => void;
const difficulty = 'both';
const difficulty = 'all';
let running = false;
let rows: Row[] = [];
let completed = 0;
@@ -94,7 +95,7 @@
while (id === runId && nextJob < jobs.length) {
const jobIndex = nextJob++;
const { chart: summary, context } = jobs[jobIndex];
const title = `${summary.title} · ${summary.difficulty === 'expert' ? 'Expert' : 'Hard'}`;
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;
@@ -221,7 +222,7 @@
<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>
<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>
@@ -232,7 +233,7 @@
<thead><tr><th scope="col">순위</th><th scope="col">곡 / 난이도</th><th scope="col">고점 레이팅</th><th scope="col">예상 고점</th><th scope="col">해당 배치 평균</th><th scope="col">점수 달성 확률</th><th scope="col"><span class="sr-only">상세 배치</span></th></tr></thead>
<tbody>{#each pageRows as row, index}<tr class:top={topSongs.includes(row)}>
<td class="rank-cell"><span class="rank-number" class:first={(pageNumber - 1) * pageSize + index === 0}>{(pageNumber - 1) * pageSize + index + 1}</span></td>
<td class="song"><strong>{row.title}</strong><div class="song-meta"><span class="difficulty" class:expert={row.difficulty === 'expert'}>{row.difficulty === 'expert' ? 'Expert' : 'Hard'} <b>{row.difficultyLevel}</b></span>{#if topSongs.includes(row)}<span class="counted">합산 대상</span>{/if}{#if row.scoreCalibrationSource !== 'formula-v1' && row.scoreCalibrationSource !== 'measured'}<span class="estimated">점수 계수 추정</span>{/if}</div></td>
<td class="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>
@@ -266,7 +267,7 @@
<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>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>
+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));
assert.equal(parsed.records.length, 2); assert.equal(parsed.snapshots.length, 1);
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;
assert.throws(() => parseAnalysisArchive(JSON.stringify(archive)));
});
+11
View File
@@ -31,3 +31,14 @@ test('고점 순위와 상위 3곡 합계는 동일 곡의 난이도를 중복
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);
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { isDifficulty } from '../data/difficulty.ts';
import { releasedSongCharts } from '../data/song-release.ts';
export type RatingChart = {
@@ -17,7 +18,7 @@ export function songRating(score: number): number {
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));
(difficulty === 'all' ? isDifficulty(chart.difficulty) : difficulty === 'both' ? chart.difficulty === 'hard' || chart.difficulty === 'expert' : chart.difficulty === difficulty));
}
export type SongRatingScore = {
+1 -1
View File
@@ -50,7 +50,7 @@ test('공식 적용 카탈로그와 실제 채보가 같은 분모 및 환산값
assert.equal(chart.notes.length, ref.fullComboNoteCount);
formulaCount += 1;
}
assert.equal(formulaCount, 388);
assert.equal(formulaCount, catalog.charts.length);
});
test('잘못된 분모와 난이도로 공식 환산값을 만들지 않는다', () => {
+2 -1
View File
@@ -1,7 +1,8 @@
import type { DifficultyKey } from '../data/difficulty.ts';
import type { CooldownReduction, ExactOptimizationResult } from '../engine/types.ts';
export type AnalysisSettings = {
songId: string; difficulty: 'hard' | 'expert'; cardIds: string[]; awakenings: number[];
songId: string; difficulty: DifficultyKey; cardIds: string[]; awakenings: number[];
fixedCooldownReductions?: (CooldownReduction | null)[];
allBlueNodes?: boolean[]; generationBonusOverrides?: (number | null)[];
songScoreBonusOverride?: number | null; leaderCharacterId: string; leaderOutfitCardId: string;
+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 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();
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 (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.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();
+1 -1
View File
@@ -33,7 +33,7 @@
</a>
<a class="feature-card" href="/rating">
<span class="feature-number">03</span>
<div><h3>리더 레이팅</h3><p>입력한 편성으로 레이팅을 높이기 좋은 가창곡의 Hard·Expert를 최대 20개 추천하고, 5개씩 비교합니다.</p></div>
<div><h3>리더 레이팅</h3><p>입력한 편성으로 레이팅을 높이기 좋은 가창곡의 모든 난이도를 최대 20개 추천하고, 5개씩 비교합니다.</p></div>
<span class="arrow" aria-hidden="true">→</span>
</a>
<a class="feature-card" href="/data/boards">
+5 -6
View File
@@ -25,7 +25,7 @@
export let mode: 'analysis' | 'rating' = 'analysis';
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 MemberBoards = Record<string, string[]>;
type BoardCards = Record<string, Record<string, string>>;
@@ -333,7 +333,7 @@
const state = JSON.parse(stored) as Partial<SavedAnalysisState>;
if (!legacy && state.schemaVersion !== 2) return;
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);
if (Array.isArray(state.cardIds) && state.cardIds.length === 5) {
@@ -901,7 +901,7 @@
<header class="page-header">
<div>
<h1>{mode === 'rating' ? '리더 레이팅' : '배치 분석'}</h1>
<p class="page-description">{mode === 'rating' ? '입력한 편성으로 리더 가창곡의 Hard·Expert를 예상 레이팅 순으로 최대 20개 추천합니다. 결과는 5개씩 확인할 수 있습니다.' : '특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.'}</p>
<p class="page-description">{mode === 'rating' ? '입력한 편성으로 리더 가창곡의 모든 난이도를 예상 레이팅 순으로 최대 20개 추천합니다. 결과는 5개씩 확인할 수 있습니다.' : '특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.'}</p>
{#if mode === 'analysis' && selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
{/if}
@@ -919,8 +919,7 @@
<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>
{#each difficulties as value}<option {value}>{difficultyLabel(value)}</option>{/each}
</select>
</label>
<label>가창 보너스 덮어쓰기 (0~10%)
@@ -1036,7 +1035,7 @@
{#if mode === 'analysis' || result}
<section class="panel result" aria-labelledby="result-title">
<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 mode === 'rating' && result}<p class="board-guide"><strong>{selectedChart?.title} · {difficultyLabel(difficulty)}</strong> · 종합력 {overallPower.toLocaleString('ko-KR')} · 가창 보너스 10%</p>{/if}
{#if result}
<div class="order-sections">
<section class="order-section">
+5 -5
View File
@@ -41,7 +41,7 @@
export let eventMode = false;
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 MemberBoards = Record<string, string[]>;
type BoardCards = Record<string, Record<string, string>>;
@@ -370,7 +370,7 @@
function applySavedState(state: Partial<SavedState>): void {
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 (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] : '');
candidateAwakening = normalizeAwakening(state.awakening);
candidateGenerationBonus = typeof state.candidateGenerationBonus === 'number' && Number.isFinite(state.candidateGenerationBonus)
@@ -422,7 +422,7 @@
return {
schemaVersion: 2,
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),
awakenings: cards.map((_, index) => Number.isFinite(state.awakenings?.[index]) ? normalizeAwakening(state.awakenings?.[index]) : 0),
fixedCooldownReductions: cards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index])),
@@ -1140,7 +1140,7 @@
<div><span>00</span><h2 id="baseline-title">기준 조합</h2></div>
<strong>배치 분석 저장값</strong>
</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">
{#each baselineResult?.formationOrder ?? baselineState.cardIds as cardId, slot}
<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="song-fields">
<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>
<div class="candidate-fields">