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:
+5
-1
@@ -1,9 +1,13 @@
|
|||||||
---
|
---
|
||||||
updatedAt: 2026-09-07T13:44:23+09:00
|
updatedAt: 2026-09-07T14:17:02+09:00
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026년 9월 7일
|
## 2026년 9월 7일
|
||||||
|
|
||||||
|
### 모든 악곡 난이도 지원
|
||||||
|
|
||||||
|
`NEW` 모든 악곡의 Easy·Normal 채보를 추가했습니다. 배치 분석과 조합 탐색에서 네 난이도를 선택할 수 있으며, 분석 기록 저장·복원과 리더 레이팅 곡 추천에도 모든 난이도를 반영합니다.
|
||||||
|
|
||||||
### 리더 레이팅 곡 추천
|
### 리더 레이팅 곡 추천
|
||||||
|
|
||||||
`NEW` 입력한 편성에 맞는 리더 가창곡을 레이팅순으로 최대 20개 추천합니다. 5개씩 보기, 상위 3곡 합계, 최고점·99%·95% 달성 확률과 상세 배치를 제공하며 병렬 계산을 지원합니다. 가창 보너스는 10%로 고정합니다.
|
`NEW` 입력한 편성에 맞는 리더 가창곡을 레이팅순으로 최대 20개 추천합니다. 5개씩 보기, 상위 3곡 합계, 최고점·99%·95% 달성 확률과 상세 배치를 제공하며 병렬 계산을 지원합니다. 가창 보너스는 10%로 고정합니다.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import { formulaScoreMetadata } from './song-score-formulas.mjs';
|
|||||||
const sourceOrigin = process.argv[2] ?? 'https://dreams.wf-calc.net';
|
const sourceOrigin = process.argv[2] ?? 'https://dreams.wf-calc.net';
|
||||||
const outputDirectory = path.resolve('static/data/charts');
|
const outputDirectory = path.resolve('static/data/charts');
|
||||||
const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json');
|
const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json');
|
||||||
const selectedDifficulties = new Set(['HARD', 'EXPERT']);
|
const selectedDifficulties = new Set(['EASY', 'NORMAL', 'HARD', 'EXPERT']);
|
||||||
const chartScoreWeightCoefficient = 0.3713416563352296;
|
const chartScoreWeightCoefficient = 0.3713416563352296;
|
||||||
const scoreCalibrations = JSON.parse(await readFile(path.resolve('data/score-calibrations.json'), 'utf8'));
|
const scoreCalibrations = JSON.parse(await readFile(path.resolve('data/score-calibrations.json'), 'utf8'));
|
||||||
const scoreCalibrationByKey = new Map(scoreCalibrations.charts.map((row) => [
|
const scoreCalibrationByKey = new Map(scoreCalibrations.charts.map((row) => [
|
||||||
@@ -219,7 +219,7 @@ for (const match of simulatorModule.matchAll(importPattern)) {
|
|||||||
if (selectedDifficulties.has(difficulty)) imports.push({ songId, difficulty, difficultyLevel, chartFile, assetFile });
|
if (selectedDifficulties.has(difficulty)) imports.push({ songId, difficulty, difficultyLevel, chartFile, assetFile });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imports.length === 0) throw new Error('수집할 Hard/Expert 채보 모듈을 찾지 못했습니다.');
|
if (imports.length === 0) throw new Error('수집할 채보 모듈을 찾지 못했습니다.');
|
||||||
await mkdir(outputDirectory, { recursive: true });
|
await mkdir(outputDirectory, { recursive: true });
|
||||||
|
|
||||||
let completed = 0;
|
let completed = 0;
|
||||||
|
|||||||
@@ -126,7 +126,7 @@
|
|||||||
{#each records as record (record.id)}
|
{#each records as record (record.id)}
|
||||||
<li class:selected={selected?.id === record.id}>
|
<li class:selected={selected?.id === record.id}>
|
||||||
<button class="record" disabled={busy} on:click={() => open(record)} aria-expanded={selected?.id === record.id}>
|
<button class="record" disabled={busy} on:click={() => open(record)} aria-expanded={selected?.id === record.id}>
|
||||||
<span class="record-meta"><span class="difficulty">{record.settings.difficulty === 'expert' ? 'EXPERT' : 'HARD'}</span><span>{new Date(record.analyzedAt).toLocaleDateString('ko-KR')}</span></span>
|
<span class="record-meta"><span class="difficulty">{record.settings.difficulty.toUpperCase()}</span><span>{new Date(record.analyzedAt).toLocaleDateString('ko-KR')}</span></span>
|
||||||
<strong class="record-name">{record.name}</strong><span class="song-name">{record.title}</span>
|
<strong class="record-name">{record.name}</strong><span class="song-name">{record.title}</span>
|
||||||
<span class="members">{#each record.settings.cardIds as id}<span>{cardName(id)}</span>{/each}</span>
|
<span class="members">{#each record.settings.cardIds as id}<span>{cardName(id)}</span>{/each}</span>
|
||||||
<span class="record-scores"><span><small>평균 점수</small><b>{record.result.expectedScore.toLocaleString('ko-KR')}</b></span><span><small>이론상 고점</small><b>{record.result.theoreticalMaxScore.toLocaleString('ko-KR')}</b></span></span>
|
<span class="record-scores"><span><small>평균 점수</small><b>{record.result.expectedScore.toLocaleString('ko-KR')}</b></span><span><small>이론상 고점</small><b>{record.result.theoreticalMaxScore.toLocaleString('ko-KR')}</b></span></span>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { difficultyLabel } from '$lib/data/difficulty.ts';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import type catalog from '$lib/data/exact-chart-catalog.json';
|
import type catalog from '$lib/data/exact-chart-catalog.json';
|
||||||
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
|
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
export let getContext: (chart: Chart) => Context;
|
export let getContext: (chart: Chart) => Context;
|
||||||
export let onSelect: (chart: Chart, result: ExactOptimizationResult, overallPower: number, probabilities: ScoreAchievementProbabilities) => void;
|
export let onSelect: (chart: Chart, result: ExactOptimizationResult, overallPower: number, probabilities: ScoreAchievementProbabilities) => void;
|
||||||
|
|
||||||
const difficulty = 'both';
|
const difficulty = 'all';
|
||||||
let running = false;
|
let running = false;
|
||||||
let rows: Row[] = [];
|
let rows: Row[] = [];
|
||||||
let completed = 0;
|
let completed = 0;
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
while (id === runId && nextJob < jobs.length) {
|
while (id === runId && nextJob < jobs.length) {
|
||||||
const jobIndex = nextJob++;
|
const jobIndex = nextJob++;
|
||||||
const { chart: summary, context } = jobs[jobIndex];
|
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 } };
|
activeJobs = { ...activeJobs, [jobIndex]: { title, fraction: 0 } };
|
||||||
let jobWorker: Worker | null = null;
|
let jobWorker: Worker | null = null;
|
||||||
let rejectJob: ((reason: Error) => void) | null = null;
|
let rejectJob: ((reason: Error) => void) | null = null;
|
||||||
@@ -221,7 +222,7 @@
|
|||||||
<div class="contributors-title"><span>레이팅에 합산되는 곡</span><small>곡별 최고 기록 1개</small></div>
|
<div class="contributors-title"><span>레이팅에 합산되는 곡</span><small>곡별 최고 기록 1개</small></div>
|
||||||
<ol>{#each topSongs as row, index}<li>
|
<ol>{#each topSongs as row, index}<li>
|
||||||
<span class="contributor-index">{String(index + 1).padStart(2, '0')}</span>
|
<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>
|
<strong>{format(songRating(row.theoreticalMaxScore))}</strong>
|
||||||
</li>{/each}</ol>
|
</li>{/each}</ol>
|
||||||
</div>
|
</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>
|
<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)}>
|
<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="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="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="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="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>
|
<summary><span class="info-mark" aria-hidden="true">i</span>계산 기준 및 안내<span class="guide-chevron" aria-hidden="true">⌄</span></summary>
|
||||||
<div>
|
<div>
|
||||||
<p>멤버 5명과 리더·의상·성장 설정을 반영하고, 곡마다 스페셜 순서와 허용된 빈도 감소를 최적화합니다. 가창 보너스는 모든 곡에 10%를 적용합니다.</p>
|
<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>점수 달성 확률은 해당 추천 배치에서 모든 노트를 PERFECT로 처리할 때의 액티브 발동 확률을 반영합니다. 최고점 확률은 정확 계산, 최고점의 99%·95% 이상 확률은 25,000회 반복 계산값입니다.</p>
|
||||||
<p>곡 레이팅 = 점수 ÷ 5,000 올림 · <a href="https://dreams.wf-calc.net/wiki/live" target="_blank" rel="noreferrer">계산 기준 (비공식 연구 자료) ↗</a>. 실제 플레이 결과와 차이가 있을 수 있습니다. 가창 멤버가 등록되지 않은 곡은 제외합니다.</p>
|
<p>곡 레이팅 = 점수 ÷ 5,000 올림 · <a href="https://dreams.wf-calc.net/wiki/live" target="_blank" rel="noreferrer">계산 기준 (비공식 연구 자료) ↗</a>. 실제 플레이 결과와 차이가 있을 수 있습니다. 가창 멤버가 등록되지 않은 곡은 제외합니다.</p>
|
||||||
</div>
|
</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));
|
const parsed = parseAnalysisArchive(JSON.stringify(archive));
|
||||||
assert.equal(parsed.records.length, 2); assert.equal(parsed.snapshots.length, 1);
|
assert.equal(parsed.records.length, 2); assert.equal(parsed.snapshots.length, 1);
|
||||||
assert.deepEqual(parsed.snapshots[0].growth, growth);
|
assert.deepEqual(parsed.snapshots[0].growth, growth);
|
||||||
|
for (const difficulty of ['easy', 'normal', 'hard', 'expert']) {
|
||||||
|
record.settings.difficulty = difficulty;
|
||||||
|
assert.equal(parseAnalysisArchive(JSON.stringify(archive)).records[0].settings.difficulty, difficulty);
|
||||||
|
}
|
||||||
|
record.settings.difficulty = 'invalid';
|
||||||
|
assert.throws(() => parseAnalysisArchive(JSON.stringify(archive)));
|
||||||
|
record.settings.difficulty = 'expert';
|
||||||
record.settings.awakenings[0] = 8;
|
record.settings.awakenings[0] = 8;
|
||||||
assert.throws(() => parseAnalysisArchive(JSON.stringify(archive)));
|
assert.throws(() => parseAnalysisArchive(JSON.stringify(archive)));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,3 +31,14 @@ test('고점 순위와 상위 3곡 합계는 동일 곡의 난이도를 중복
|
|||||||
assert.equal(leaderRating([]), 0);
|
assert.equal(leaderRating([]), 0);
|
||||||
assert.equal(results[0].theoreticalMaxScore, 5001);
|
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';
|
import { releasedSongCharts } from '../data/song-release.ts';
|
||||||
|
|
||||||
export type RatingChart = {
|
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[] {
|
export function leaderRatingCharts<T extends RatingChart>(charts: readonly T[], leaderId: string, difficulty: string, now: number): T[] {
|
||||||
return releasedSongCharts(charts, now).filter((chart) =>
|
return releasedSongCharts(charts, now).filter((chart) =>
|
||||||
Boolean(leaderId) && chart.singerCharacterIds?.includes(leaderId) &&
|
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 = {
|
export type SongRatingScore = {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ test('공식 적용 카탈로그와 실제 채보가 같은 분모 및 환산값
|
|||||||
assert.equal(chart.notes.length, ref.fullComboNoteCount);
|
assert.equal(chart.notes.length, ref.fullComboNoteCount);
|
||||||
formulaCount += 1;
|
formulaCount += 1;
|
||||||
}
|
}
|
||||||
assert.equal(formulaCount, 388);
|
assert.equal(formulaCount, catalog.charts.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('잘못된 분모와 난이도로 공식 환산값을 만들지 않는다', () => {
|
test('잘못된 분모와 난이도로 공식 환산값을 만들지 않는다', () => {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import type { DifficultyKey } from '../data/difficulty.ts';
|
||||||
import type { CooldownReduction, ExactOptimizationResult } from '../engine/types.ts';
|
import type { CooldownReduction, ExactOptimizationResult } from '../engine/types.ts';
|
||||||
|
|
||||||
export type AnalysisSettings = {
|
export type AnalysisSettings = {
|
||||||
songId: string; difficulty: 'hard' | 'expert'; cardIds: string[]; awakenings: number[];
|
songId: string; difficulty: DifficultyKey; cardIds: string[]; awakenings: number[];
|
||||||
fixedCooldownReductions?: (CooldownReduction | null)[];
|
fixedCooldownReductions?: (CooldownReduction | null)[];
|
||||||
allBlueNodes?: boolean[]; generationBonusOverrides?: (number | null)[];
|
allBlueNodes?: boolean[]; generationBonusOverrides?: (number | null)[];
|
||||||
songScoreBonusOverride?: number | null; leaderCharacterId: string; leaderOutfitCardId: string;
|
songScoreBonusOverride?: number | null; leaderCharacterId: string; leaderOutfitCardId: string;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isDifficulty } from '../data/difficulty.ts';
|
||||||
import { compactResult, snapshotKey, type AnalysisCapture, type AnalysisRecord, type GrowthSnapshot } from '../models/analysis-history.ts';
|
import { compactResult, snapshotKey, type AnalysisCapture, type AnalysisRecord, type GrowthSnapshot } from '../models/analysis-history.ts';
|
||||||
import type { ExactOptimizationResult } from '../engine/types.ts';
|
import type { ExactOptimizationResult } from '../engine/types.ts';
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export function parseAnalysisArchive(text: string): Archive {
|
|||||||
if (![record.id, record.name, record.title, record.analyzedAt].every((s) => typeof s === 'string') || !Number.isFinite(Date.parse(record.analyzedAt))) return invalid();
|
if (![record.id, record.name, record.title, record.analyzedAt].every((s) => typeof s === 'string') || !Number.isFinite(Date.parse(record.analyzedAt))) return invalid();
|
||||||
const s = record.settings, r = record.result;
|
const s = record.settings, r = record.result;
|
||||||
if (!strings(s.cardIds) || s.cardIds.length !== 5 || new Set(s.cardIds).size !== 5 || !Array.isArray(s.awakenings) || s.awakenings.length !== 5 || !s.awakenings.every((n) => Number.isInteger(n) && n >= 0 && n <= 5)) return invalid();
|
if (!strings(s.cardIds) || s.cardIds.length !== 5 || new Set(s.cardIds).size !== 5 || !Array.isArray(s.awakenings) || s.awakenings.length !== 5 || !s.awakenings.every((n) => Number.isInteger(n) && n >= 0 && n <= 5)) return invalid();
|
||||||
if (typeof s.songId !== 'string' || !['hard', 'expert'].includes(s.difficulty) || typeof s.leaderCharacterId !== 'string' || typeof s.leaderOutfitCardId !== 'string') return invalid();
|
if (typeof s.songId !== 'string' || !isDifficulty(s.difficulty) || typeof s.leaderCharacterId !== 'string' || typeof s.leaderOutfitCardId !== 'string') return invalid();
|
||||||
if (s.allBlueNodes && (s.allBlueNodes.length !== 5 || !s.allBlueNodes.every((v) => typeof v === 'boolean'))) return invalid();
|
if (s.allBlueNodes && (s.allBlueNodes.length !== 5 || !s.allBlueNodes.every((v) => typeof v === 'boolean'))) return invalid();
|
||||||
if (s.fixedCooldownReductions && (s.fixedCooldownReductions.length !== 5 || !s.fixedCooldownReductions.every((v) => v === null || [0, 4, 8, 12].includes(v)))) return invalid();
|
if (s.fixedCooldownReductions && (s.fixedCooldownReductions.length !== 5 || !s.fixedCooldownReductions.every((v) => v === null || [0, 4, 8, 12].includes(v)))) return invalid();
|
||||||
if (s.generationBonusOverrides && (s.generationBonusOverrides.length !== 5 || !s.generationBonusOverrides.every((v) => v === null || (finite(v) && v <= 900)))) return invalid();
|
if (s.generationBonusOverrides && (s.generationBonusOverrides.length !== 5 || !s.generationBonusOverrides.every((v) => v === null || (finite(v) && v <= 900)))) return invalid();
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<a class="feature-card" href="/rating">
|
<a class="feature-card" href="/rating">
|
||||||
<span class="feature-number">03</span>
|
<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>
|
<span class="arrow" aria-hidden="true">→</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="feature-card" href="/data/boards">
|
<a class="feature-card" href="/data/boards">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
export let mode: 'analysis' | 'rating' = 'analysis';
|
export let mode: 'analysis' | 'rating' = 'analysis';
|
||||||
|
|
||||||
type CardRow = (typeof cardSummary.cards)[number];
|
type CardRow = (typeof cardSummary.cards)[number];
|
||||||
type DifficultyKey = 'hard' | 'expert';
|
import { difficulties, difficultyLabel, isDifficulty, type DifficultyKey } from '$lib/data/difficulty.ts';
|
||||||
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
|
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
|
||||||
type MemberBoards = Record<string, string[]>;
|
type MemberBoards = Record<string, string[]>;
|
||||||
type BoardCards = Record<string, Record<string, string>>;
|
type BoardCards = Record<string, Record<string, string>>;
|
||||||
@@ -333,7 +333,7 @@
|
|||||||
const state = JSON.parse(stored) as Partial<SavedAnalysisState>;
|
const state = JSON.parse(stored) as Partial<SavedAnalysisState>;
|
||||||
if (!legacy && state.schemaVersion !== 2) return;
|
if (!legacy && state.schemaVersion !== 2) return;
|
||||||
if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
|
if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
|
||||||
if (state.difficulty === 'hard' || state.difficulty === 'expert') difficulty = state.difficulty;
|
if (isDifficulty(state.difficulty)) difficulty = state.difficulty;
|
||||||
songScoreBonusOverride = normalizeSongScoreBonusOverride(state.songScoreBonusOverride);
|
songScoreBonusOverride = normalizeSongScoreBonusOverride(state.songScoreBonusOverride);
|
||||||
|
|
||||||
if (Array.isArray(state.cardIds) && state.cardIds.length === 5) {
|
if (Array.isArray(state.cardIds) && state.cardIds.length === 5) {
|
||||||
@@ -901,7 +901,7 @@
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>{mode === 'rating' ? '리더 레이팅' : '배치 분석'}</h1>
|
<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'}
|
{#if mode === 'analysis' && selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
|
||||||
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
|
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -919,8 +919,7 @@
|
|||||||
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
|
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
|
||||||
<label>난이도
|
<label>난이도
|
||||||
<select bind:value={difficulty} on:change={() => result = null}>
|
<select bind:value={difficulty} on:change={() => result = null}>
|
||||||
<option value="expert">Expert</option>
|
{#each difficulties as value}<option {value}>{difficultyLabel(value)}</option>{/each}
|
||||||
<option value="hard">Hard</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>가창 보너스 덮어쓰기 (0~10%)
|
<label>가창 보너스 덮어쓰기 (0~10%)
|
||||||
@@ -1036,7 +1035,7 @@
|
|||||||
{#if mode === 'analysis' || result}
|
{#if mode === 'analysis' || result}
|
||||||
<section class="panel result" aria-labelledby="result-title">
|
<section class="panel result" aria-labelledby="result-title">
|
||||||
<div class="section-title"><div><span>{mode === 'rating' ? '03' : '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 mode === 'rating' && result}<p class="board-guide"><strong>{selectedChart?.title} · {difficultyLabel(difficulty)}</strong> · 종합력 {overallPower.toLocaleString('ko-KR')} · 가창 보너스 10%</p>{/if}
|
||||||
{#if result}
|
{#if result}
|
||||||
<div class="order-sections">
|
<div class="order-sections">
|
||||||
<section class="order-section">
|
<section class="order-section">
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
export let eventMode = false;
|
export let eventMode = false;
|
||||||
|
|
||||||
type CardRow = (typeof cardSummary.cards)[number];
|
type CardRow = (typeof cardSummary.cards)[number];
|
||||||
type DifficultyKey = 'hard' | 'expert';
|
import { difficulties, difficultyLabel, isDifficulty, type DifficultyKey } from '$lib/data/difficulty.ts';
|
||||||
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
|
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
|
||||||
type MemberBoards = Record<string, string[]>;
|
type MemberBoards = Record<string, string[]>;
|
||||||
type BoardCards = Record<string, Record<string, string>>;
|
type BoardCards = Record<string, Record<string, string>>;
|
||||||
@@ -370,7 +370,7 @@
|
|||||||
function applySavedState(state: Partial<SavedState>): void {
|
function applySavedState(state: Partial<SavedState>): void {
|
||||||
if (state.schemaVersion !== 1) throw new Error('Unsupported preset');
|
if (state.schemaVersion !== 1) throw new Error('Unsupported preset');
|
||||||
if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
|
if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
|
||||||
if (state.difficulty === 'hard' || state.difficulty === 'expert') difficulty = state.difficulty;
|
if (isDifficulty(state.difficulty)) difficulty = state.difficulty;
|
||||||
fixedCardIds = Array.from({ length: 4 }, (_, index) => cardById.has(state.fixedCardIds?.[index] ?? '') ? state.fixedCardIds![index] : '');
|
fixedCardIds = Array.from({ length: 4 }, (_, index) => cardById.has(state.fixedCardIds?.[index] ?? '') ? state.fixedCardIds![index] : '');
|
||||||
candidateAwakening = normalizeAwakening(state.awakening);
|
candidateAwakening = normalizeAwakening(state.awakening);
|
||||||
candidateGenerationBonus = typeof state.candidateGenerationBonus === 'number' && Number.isFinite(state.candidateGenerationBonus)
|
candidateGenerationBonus = typeof state.candidateGenerationBonus === 'number' && Number.isFinite(state.candidateGenerationBonus)
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
return {
|
return {
|
||||||
schemaVersion: 2,
|
schemaVersion: 2,
|
||||||
songId: typeof state.songId === 'string' ? state.songId : '',
|
songId: typeof state.songId === 'string' ? state.songId : '',
|
||||||
difficulty: state.difficulty === 'hard' ? 'hard' : 'expert',
|
difficulty: isDifficulty(state.difficulty) ? state.difficulty : 'expert',
|
||||||
cardIds: cards.map((card) => card.id),
|
cardIds: cards.map((card) => card.id),
|
||||||
awakenings: cards.map((_, index) => Number.isFinite(state.awakenings?.[index]) ? normalizeAwakening(state.awakenings?.[index]) : 0),
|
awakenings: cards.map((_, index) => Number.isFinite(state.awakenings?.[index]) ? normalizeAwakening(state.awakenings?.[index]) : 0),
|
||||||
fixedCooldownReductions: cards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index])),
|
fixedCooldownReductions: cards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index])),
|
||||||
@@ -1140,7 +1140,7 @@
|
|||||||
<div><span>00</span><h2 id="baseline-title">기준 조합</h2></div>
|
<div><span>00</span><h2 id="baseline-title">기준 조합</h2></div>
|
||||||
<strong>배치 분석 저장값</strong>
|
<strong>배치 분석 저장값</strong>
|
||||||
</div>
|
</div>
|
||||||
<p class="baseline-condition">{baselineChartMetadata?.title} · {baselineState.difficulty === 'expert' ? 'Expert' : 'Hard'}</p>
|
<p class="baseline-condition">{baselineChartMetadata?.title} · {difficultyLabel(baselineState.difficulty)}</p>
|
||||||
<div class="baseline-members">
|
<div class="baseline-members">
|
||||||
{#each baselineResult?.formationOrder ?? baselineState.cardIds as cardId, slot}
|
{#each baselineResult?.formationOrder ?? baselineState.cardIds as cardId, slot}
|
||||||
<div><span>{slot + 1}</span><strong>{baselineCardLabel(cardId)}</strong></div>
|
<div><span>{slot + 1}</span><strong>{baselineCardLabel(cardId)}</strong></div>
|
||||||
@@ -1164,7 +1164,7 @@
|
|||||||
<div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>{ownedCardsOnly ? (eventMode ? '보유 이벤트 후보 대상' : '보유 5성 대상') : (eventMode ? '5성 + 대상 멤버 전 레어리티' : '5성 전체 대상')}</strong></div>
|
<div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>{ownedCardsOnly ? (eventMode ? '보유 이벤트 후보 대상' : '보유 5성 대상') : (eventMode ? '5성 + 대상 멤버 전 레어리티' : '5성 전체 대상')}</strong></div>
|
||||||
<div class="song-fields">
|
<div class="song-fields">
|
||||||
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
|
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
|
||||||
<label>난이도<select bind:value={difficulty} on:change={() => result = null}><option value="expert">Expert</option><option value="hard">Hard</option></select></label>
|
<label>난이도<select bind:value={difficulty} on:change={() => result = null}>{#each difficulties as value}<option {value}>{difficultyLabel(value)}</option>{/each}</select></label>
|
||||||
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>점수 계수 · {scoreCalibrationLabel}</span></div>
|
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>점수 계수 · {scoreCalibrationLabel}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="candidate-fields">
|
<div class="candidate-fields">
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user