feat: support every song difficulty including Easy and Normal
Publish GitHub Pages output / publish (push) Successful in 41s
Publish GitHub Pages output / publish (push) Successful in 41s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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('잘못된 분모와 난이도로 공식 환산값을 만들지 않는다', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user