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();