From 50421280fa96e395c9bd1385a6973705a4d935d8 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Thu, 10 Sep 2026 21:33:43 +0900 Subject: [PATCH] feat: add random song tool with level filters and shareable history --- UPDATES.md | 10 +- offline/OfflineApp.svelte | 5 + src/lib/engine/random-song.test.ts | 56 +++++++ src/lib/engine/random-song.ts | 28 ++++ src/routes/+layout.svelte | 1 + src/routes/+page.svelte | 5 + src/routes/random/+page.svelte | 5 + src/routes/tools/+page.svelte | 214 +++++++++++++++++++++++++++ src/routes/tools/random/+page.svelte | 5 + vite.config.ts | 5 +- 10 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 src/lib/engine/random-song.test.ts create mode 100644 src/lib/engine/random-song.ts create mode 100644 src/routes/random/+page.svelte create mode 100644 src/routes/tools/+page.svelte create mode 100644 src/routes/tools/random/+page.svelte diff --git a/UPDATES.md b/UPDATES.md index 3388c95..a487f57 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,9 +1,17 @@ --- -updatedAt: 2026-09-10T14:39:25+09:00 +updatedAt: 2026-09-10T21:32:35+09:00 --- ## 2026년 9월 10일 +### 악곡 랜덤 선택 + +상단 ‘업데이트 내역’ 오른쪽의 ‘도구’ 메뉴에서 하위 ‘악곡 랜덤’을 이용할 수 있습니다. + +`NEW` 모든 악곡·난이도 조합에서 하나를 같은 확률로 추첨하는 ‘악곡 랜덤’을 추가했습니다. 최소·최대 레벨을 지정하면 양 끝 레벨을 포함한 구간에서만 추첨하며, 같은 레벨을 지정할 수도 있습니다. 추첨 결과는 뽑힌 순서대로 목록에 누적되며, 레벨 범위를 바꿔도 유지됩니다. ‘목록 초기화’를 누르면 결과와 순번을 초기화합니다. + +‘목록 복사’로 결과를 `1. 악곡명 / 난이도 / Lv. 25` 형식의 텍스트로 한 번에 복사할 수 있습니다. 자동 복사가 지원되지 않으면 직접 복사할 수 있는 텍스트를 표시합니다. + ### 시간별 최고점 유지 확률 `NEW` 배치 분석의 시간별 점수 그래프에 커서를 올리면 해당 시각까지 최고점을 유지할 확률을 함께 표시합니다. 모든 노트 PERFECT와 액티브 발동 확률을 기준으로 계산하며, 액티브 미발동 토글 시 현재 설정의 최고점 기준으로 갱신됩니다. diff --git a/offline/OfflineApp.svelte b/offline/OfflineApp.svelte index 1cca795..ed4f834 100644 --- a/offline/OfflineApp.svelte +++ b/offline/OfflineApp.svelte @@ -6,6 +6,7 @@ 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 ToolsPage from '../src/routes/tools/+page.svelte'; import EventPage from '../src/routes/event/+page.svelte'; import DataPage from '../src/routes/data/+page.svelte'; import DataCardsPage from '../src/routes/data/cards/+page.svelte'; @@ -21,6 +22,9 @@ ['/analysis', AnalysisPage], ['/rating', RatingPage], ['/combination', CombinationPage], + ['/tools', ToolsPage], + ['/tools/random', ToolsPage], + ['/random', ToolsPage], ['/event', EventPage], ['/data', DataPage], ['/data/cards', DataCardsPage], @@ -85,6 +89,7 @@ {#if showEventTab}이벤트 Pt{/if} 사용자 데이터 업데이트 내역 + 도구 { + assert.equal(formatRandomSongResults([charts[3], charts[0], charts[3]]), + '1. A / Expert / Lv. 25\n2. A / Easy / Lv. 5\n3. A / Expert / Lv. 25'); + assert.equal(formatRandomSongResults([]), ''); +}); + +test('복사할 제목의 줄바꿈을 정리하고 레벨이 없는 결과도 표시한다', () => { + assert.equal(formatRandomSongResults([{ ...charts[6], title: ' Song\nTitle\t ' }]), + '1. Song Title / Easy / 레벨 정보 없음'); +}); + +test('범위를 지정하지 않으면 모든 악곡과 난이도 조합을 포함한다', () => { + assert.deepEqual(randomSongCandidates(charts), charts); +}); + +test('범위의 양 끝 레벨을 포함하며 같은 레벨만 지정할 수 있다', () => { + assert.deepEqual(randomSongCandidates(charts, { min: 10, max: 25 }), charts.slice(1, 5)); + assert.deepEqual(randomSongCandidates(charts, { min: 15, max: 15 }), [charts[2], charts[4]]); +}); + +test('해당 레벨이 없거나 범위가 잘못되면 후보를 반환하지 않는다', () => { + for (const range of [{ min: 16, max: 24 }, { min: 30, max: 5 }, { min: NaN, max: 30 }, { min: 5, max: Infinity }]) { + assert.deepEqual(randomSongCandidates(charts, range), []); + } +}); + +test('모든 조합이 동일한 크기의 난수 구간으로 선택되고 첫 후보와 마지막 후보도 선택된다', () => { + const candidates = randomSongCandidates(charts); + for (let index = 0; index < candidates.length; index++) { + for (const offset of [0, 0.5, 0.999]) { + assert.equal(pickRandomSong(candidates, () => (index + offset) / candidates.length), candidates[index]); + } + } + assert.equal(pickRandomSong(candidates, () => 1 - Number.EPSILON), candidates.at(-1)); +}); + +test('추첨은 레벨 조건을 지키고 후보가 하나거나 없어도 처리한다', () => { + const candidates = randomSongCandidates(charts, { min: 30, max: 30 }); + assert.equal(pickRandomSong(candidates, () => 0.9), charts[5]); + assert.equal(pickRandomSong(candidates, () => 0), charts[5]); + assert.equal(pickRandomSong([], () => { throw new Error('빈 후보는 난수를 사용하지 않아야 한다'); }), null); +}); diff --git a/src/lib/engine/random-song.ts b/src/lib/engine/random-song.ts new file mode 100644 index 0000000..a9cce0e --- /dev/null +++ b/src/lib/engine/random-song.ts @@ -0,0 +1,28 @@ +import type { SongChartSummary } from '../data/song-catalog.ts'; +import { difficultyLabel } from '../data/difficulty.ts'; + +export function formatRandomSongResults(results: readonly SongChartSummary[]): string { + return results.map((result, index) => { + const title = result.title.replace(/\s+/g, ' ').trim(); + const level = result.difficultyLevel == null ? '레벨 정보 없음' : `Lv. ${result.difficultyLevel}`; + return `${index + 1}. ${title} / ${difficultyLabel(result.difficulty)} / ${level}`; + }).join('\n'); +} + +export type SongLevelRange = { min: number; max: number }; + +export function randomSongCandidates( + charts: readonly T[], + range?: SongLevelRange +): T[] { + if (!range) return [...charts]; + if (!Number.isFinite(range.min) || !Number.isFinite(range.max) || range.min > range.max) return []; + return charts.filter(({ difficultyLevel: level }) => + typeof level === 'number' && Number.isFinite(level) && level >= range.min && level <= range.max); +} + +/** Each chart (song/difficulty pair) has the same probability, including on repeat draws. */ +export function pickRandomSong(candidates: readonly T[], random = Math.random): T | null { + if (candidates.length === 0) return null; + return candidates[Math.floor(random() * candidates.length)] ?? null; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5ec98e4..9bd11dc 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -37,6 +37,7 @@ {#if showEventTab}이벤트 Pt{/if} 사용자 데이터 업데이트 내역 + 도구

카드 목록

카드의 타입과 주스탯, 패시브·액티브·스페셜 스킬 정보를 빠르게 찾아봅니다.

+ + 06 +

도구

악곡 랜덤 등 플레이에 유용한 도구를 이용합니다. 원하는 레벨 범위에서 악곡과 난이도를 뽑을 수 있습니다.

+ +
diff --git a/src/routes/random/+page.svelte b/src/routes/random/+page.svelte new file mode 100644 index 0000000..64361c4 --- /dev/null +++ b/src/routes/random/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/routes/tools/+page.svelte b/src/routes/tools/+page.svelte new file mode 100644 index 0000000..4c72649 --- /dev/null +++ b/src/routes/tools/+page.svelte @@ -0,0 +1,214 @@ + + + + 악곡 랜덤 · 도구 · 홀로도리 계산기 + + + +
+
+

도구

+

플레이할 때 유용한 도구를 모았습니다.

+
+ + + +
+

악곡 랜덤

+

모든 악곡의 모든 난이도에서 악곡·난이도 한 조합을 무작위로 선택합니다.

+
+ +
+ + {#if useLevelRange} +
+ + + +
+

최소·최대 레벨을 포함합니다. 같은 레벨로 지정하면 해당 레벨만 추첨합니다.

+ {/if} +

+ {#if invalidRange}최소 레벨은 최대 레벨 이하로 지정해 주세요. + {:else if candidates.length === 0}조건에 맞는 악곡·난이도가 없습니다. 레벨 범위를 변경해 주세요. + {:else}{songCount}곡 · {candidates.length}개 악곡·난이도 조합에서 추첨{/if} +

+ +

각 악곡·난이도 조합의 선택 확률은 동일하며, 같은 결과가 다시 나올 수 있습니다.

+
+ +

{announcement}

+
+
+

추첨 결과 {results.length}개

+
+ + +
+
+

먼저 뽑은 결과부터 표시합니다. 레벨 범위를 바꿔도 이전 결과는 유지됩니다.

+

{copyStatus}

+ {#if showCopyText} + + {/if} + {#if results.length} +
    + {#each results as result, index} +
  1. + + {result.title} +
    {difficultyLabel(result.difficulty)}{result.difficultyLevel == null ? '레벨 정보 없음' : `Lv. ${result.difficultyLevel}`}
    +
  2. + {/each} +
+ {:else} +

랜덤 선택을 누르면 악곡과 난이도를 함께 뽑습니다.

+ {/if} +
+
+ + diff --git a/src/routes/tools/random/+page.svelte b/src/routes/tools/random/+page.svelte new file mode 100644 index 0000000..ec15112 --- /dev/null +++ b/src/routes/tools/random/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/vite.config.ts b/vite.config.ts index 316b3ee..f2c2394 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,10 +6,11 @@ export default defineConfig({ server: { host: '0.0.0.0', port: 5173, + // SvelteKit excludes build output itself, but must watch generated route modules. + // Ignoring all of .svelte-kit leaves cached page imports pointing at old route IDs. watch: { usePolling: process.env.WATCH_USE_POLLING === 'true', - interval: 1000, - ignored: ['**/.svelte-kit/**'] + interval: 1000 } } });