feat: add random song tool with level filters and shareable history
Publish GitHub Pages output / publish (push) Successful in 53s

This commit is contained in:
2026-09-10 21:33:43 +09:00
parent 1fa15cbe08
commit 50421280fa
10 changed files with 331 additions and 3 deletions
+56
View File
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatRandomSongResults, pickRandomSong, randomSongCandidates } from './random-song.ts';
const charts = [
{ songId: 'a', title: 'A', difficulty: 'easy', difficultyLevel: 5 },
{ songId: 'a', title: 'A', difficulty: 'normal', difficultyLevel: 10 },
{ songId: 'a', title: 'A', difficulty: 'hard', difficultyLevel: 15 },
{ songId: 'a', title: 'A', difficulty: 'expert', difficultyLevel: 25 },
{ songId: 'b', title: 'B', difficulty: 'hard', difficultyLevel: 15 },
{ songId: 'b', title: 'B', difficulty: 'expert', difficultyLevel: 30 },
{ songId: 'c', title: 'C', difficulty: 'easy', difficultyLevel: null }
];
test('복사할 결과는 추첨 순서와 중복을 유지하며 한 줄씩 표시한다', () => {
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);
});
+28
View File
@@ -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<T extends SongChartSummary>(
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<T>(candidates: readonly T[], random = Math.random): T | null {
if (candidates.length === 0) return null;
return candidates[Math.floor(random() * candidates.length)] ?? null;
}
+1
View File
@@ -37,6 +37,7 @@
{#if showEventTab}<a href="/event" aria-current={$page.url.pathname.startsWith('/event') ? 'page' : undefined}>이벤트 Pt</a>{/if}
<a href="/data" aria-current={$page.url.pathname.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
<a href="/updates" aria-current={$page.url.pathname.startsWith('/updates') ? 'page' : undefined}>업데이트 내역</a>
<a href="/tools" aria-current={$page.url.pathname.startsWith('/tools') || $page.url.pathname === '/random' ? 'page' : undefined}>도구</a>
<a
class="feedback-link"
href="https://docs.google.com/forms/d/1z9c0c1crbCO6avXaYwrHZjhw2ib3zgAmW4ijJDTo4DM"
+5
View File
@@ -46,6 +46,11 @@
<div><h3>카드 목록</h3><p>카드의 타입과 주스탯, 패시브·액티브·스페셜 스킬 정보를 빠르게 찾아봅니다.</p></div>
<span class="arrow" aria-hidden="true">→</span>
</a>
<a class="feature-card" href="/tools">
<span class="feature-number">06</span>
<div><h3>도구</h3><p>악곡 랜덤 등 플레이에 유용한 도구를 이용합니다. 원하는 레벨 범위에서 악곡과 난이도를 뽑을 수 있습니다.</p></div>
<span class="arrow" aria-hidden="true">→</span>
</a>
</div>
</section>
</main>
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import ToolsPage from '../tools/+page.svelte';
</script>
<ToolsPage />
+214
View File
@@ -0,0 +1,214 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { difficultyLabel } from '$lib/data/difficulty.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts';
import { formatRandomSongResults, pickRandomSong, randomSongCandidates } from '$lib/engine/random-song.ts';
type Chart = (typeof exactChartCatalog.charts)[number];
let releaseClock = createInitialReleaseClock($page.data?.serverNow);
let releaseNow = releaseClock.now();
let useLevelRange = false;
let minLevel: number | undefined;
let maxLevel: number | undefined;
let results: Chart[] = [];
let announcement = '';
let copying = false;
let copyStatus = '';
let showCopyText = false;
let copyTextArea: HTMLTextAreaElement | undefined;
$: resultsText = formatRandomSongResults(results);
$: charts = releasedSongCharts(exactChartCatalog.charts, releaseNow);
$: levels = [...new Set(charts.map((chart) => chart.difficultyLevel)
.filter((level): level is number => typeof level === 'number' && Number.isFinite(level)))].sort((a, b) => a - b);
$: if (minLevel === undefined && levels.length) minLevel = levels[0];
$: if (maxLevel === undefined && levels.length) maxLevel = levels[levels.length - 1];
$: invalidRange = useLevelRange && (minLevel === undefined || maxLevel === undefined || minLevel > maxLevel);
$: candidates = randomSongCandidates(charts, useLevelRange ? { min: minLevel ?? NaN, max: maxLevel ?? NaN } : undefined);
$: songCount = new Set(candidates.map((chart) => chart.songId)).size;
function draw() {
const selected = pickRandomSong(candidates);
if (!selected) return;
results = [...results, selected];
copyStatus = '';
showCopyText = false;
announcement = `${results.length}번째 추첨: ${selected.title}, ${difficultyLabel(selected.difficulty)}, ${selected.difficultyLevel == null ? '레벨 정보 없음' : `Lv. ${selected.difficultyLevel}`}`;
}
function clearResults() {
results = [];
copyStatus = '';
showCopyText = false;
announcement = '추첨 목록을 초기화했습니다.';
}
async function copyResults() {
if (!results.length || copying) return;
const snapshot = results;
const text = resultsText;
copying = true;
copyStatus = '';
showCopyText = false;
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
await navigator.clipboard.writeText(text);
if (results === snapshot) copyStatus = `${snapshot.length}개 결과를 복사했습니다.`;
} catch {
if (results !== snapshot) return;
copyStatus = '자동 복사를 사용할 수 없습니다. 아래 텍스트를 선택해 복사해 주세요.';
showCopyText = true;
await tick();
copyTextArea?.focus();
copyTextArea?.select();
} finally {
copying = false;
}
}
onMount(() => {
let mounted = true;
void synchronizeReleaseClock().then((clock) => {
if (!mounted) return;
releaseClock = clock;
releaseNow = clock.now();
});
const timer = window.setInterval(() => releaseNow = releaseClock.now(), 1_000);
return () => { mounted = false; window.clearInterval(timer); };
});
</script>
<svelte:head>
<title>악곡 랜덤 · 도구 · 홀로도리 계산기</title>
<meta name="description" content="모든 악곡과 난이도에서 무작위로 선택하세요. 원하는 레벨 범위로 추첨할 수 있습니다." />
</svelte:head>
<main>
<header>
<h1>도구</h1>
<p>플레이할 때 유용한 도구를 모았습니다.</p>
</header>
<nav class="tools-nav" aria-label="도구 메뉴">
<a href="/tools/random" aria-current="page">악곡 랜덤</a>
</nav>
<div class="tool-heading">
<h2>악곡 랜덤</h2>
<p>모든 악곡의 모든 난이도에서 악곡·난이도 한 조합을 무작위로 선택합니다.</p>
</div>
<section class="panel" aria-label="추첨 조건">
<label class="toggle"><input type="checkbox" bind:checked={useLevelRange}> 레벨 범위 지정</label>
{#if useLevelRange}
<div class="range">
<label>최소 레벨
<select bind:value={minLevel} aria-invalid={invalidRange} aria-describedby="candidate-status">
{#each levels as level}<option value={level}>Lv. {level}</option>{/each}
</select>
</label>
<span class="separator" aria-hidden="true">~</span>
<label>최대 레벨
<select bind:value={maxLevel} aria-invalid={invalidRange} aria-describedby="candidate-status">
{#each levels as level}<option value={level}>Lv. {level}</option>{/each}
</select>
</label>
</div>
<p class="hint">최소·최대 레벨을 포함합니다. 같은 레벨로 지정하면 해당 레벨만 추첨합니다.</p>
{/if}
<p id="candidate-status" class:error={invalidRange || candidates.length === 0} aria-live="polite">
{#if invalidRange}최소 레벨은 최대 레벨 이하로 지정해 주세요.
{:else if candidates.length === 0}조건에 맞는 악곡·난이도가 없습니다. 레벨 범위를 변경해 주세요.
{:else}{songCount}곡 · {candidates.length}개 악곡·난이도 조합에서 추첨{/if}
</p>
<button type="button" on:click={draw} disabled={candidates.length === 0}>{results.length ? '다시 뽑기' : '랜덤 선택'}</button>
<p class="hint">각 악곡·난이도 조합의 선택 확률은 동일하며, 같은 결과가 다시 나올 수 있습니다.</p>
</section>
<p class="sr-only" role="status" aria-atomic="true">{announcement}</p>
<section class="result" aria-labelledby="results-title">
<div class="results-heading">
<h2 id="results-title">추첨 결과 <span>{results.length}개</span></h2>
<div class="result-actions">
<button class="clear-button" type="button" on:click={copyResults} disabled={results.length === 0 || copying}>{copying ? '복사 중…' : '목록 복사'}</button>
<button class="clear-button" type="button" on:click={clearResults} disabled={results.length === 0}>목록 초기화</button>
</div>
</div>
<p class="hint">먼저 뽑은 결과부터 표시합니다. 레벨 범위를 바꿔도 이전 결과는 유지됩니다.</p>
<p class="copy-status" role="status">{copyStatus}</p>
{#if showCopyText}
<label class="copy-text">복사용 텍스트
<textarea bind:this={copyTextArea} value={resultsText} readonly rows={Math.min(results.length + 1, 8)} on:focus={(event) => event.currentTarget.select()}></textarea>
</label>
{/if}
{#if results.length}
<ol class="results-list" aria-label="추첨 순서">
{#each results as result, index}
<li class:latest={index === results.length - 1}>
<span class="draw-number" aria-hidden="true">{index + 1}</span>
<strong class="song-title">{result.title}</strong>
<div class="badges"><span>{difficultyLabel(result.difficulty)}</span><strong>{result.difficultyLevel == null ? '레벨 정보 없음' : `Lv. ${result.difficultyLevel}`}</strong></div>
</li>
{/each}
</ol>
{:else}
<p class="placeholder">랜덤 선택을 누르면 악곡과 난이도를 함께 뽑습니다.</p>
{/if}
</section>
</main>
<style>
main { width: min(760px, calc(100% - var(--page-gutter))); margin: 0 auto; padding: 42px 0 80px; }
h1 { margin: 0 0 14px; font-size: clamp(30px, 5vw, 42px); letter-spacing: -.04em; }
header > p:last-child { color: #a4adba; line-height: 1.7; }
.tools-nav { display: flex; gap: 5px; margin: 24px 0; padding: 5px; border: 1px solid #2c3441; border-radius: 12px; background: #171c24; }
.tools-nav a { flex: 1; padding: 9px 12px; border-radius: 8px; color: #929cad; font-size: 14px; font-weight: 800; text-align: center; text-decoration: none; }
.tools-nav a:hover { color: #dfe4eb; background: rgb(255 255 255 / 4%); }
.tools-nav a[aria-current='page'] { color: #f3a5c5; background: rgb(239 139 182 / 12%); }
.tools-nav a:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
.tool-heading p { color: #a4adba; line-height: 1.7; margin-bottom: 0; }
.panel, .result { margin-top: 26px; padding: 26px; border: 1px solid #343c49; border-radius: 16px; background: #151a22; }
.toggle { display: flex; align-items: center; gap: 10px; font-weight: 800; }
.toggle input { width: 18px; height: 18px; accent-color: #ed8bb5; }
.range { display: grid; grid-template-columns: 1fr auto 1fr; gap: 14px; align-items: end; margin-top: 22px; }
.range label { display: grid; gap: 8px; min-width: 0; color: #a4adba; font-size: 14px; }
.separator { padding-bottom: 12px; color: #a4adba; }
select { width: 100%; height: 44px; padding: 0 12px; border: 1px solid #414b5b; border-radius: 9px; background: #101319; color: #e8ebf1; font: inherit; }
.hint { margin: 14px 0 0; color: #a4adba; font-size: 13px; line-height: 1.6; }
#candidate-status { margin: 24px 0 16px; color: #bec8d6; font-size: 14px; line-height: 1.6; }
#candidate-status.error { color: #f3a1a1; }
button { width: 100%; min-height: 48px; border: 0; border-radius: 10px; background: #ed8bb5; color: #21121a; font: inherit; font-weight: 850; cursor: pointer; }
button:hover:not(:disabled) { background: #f4a8c9; }
button:disabled { background: #343c49; color: #a4adba; cursor: not-allowed; }
button:focus-visible, select:focus-visible, input:focus-visible, textarea:focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
.result { border-color: #694254; background: rgb(237 139 181 / 6%); }
.results-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.result-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.copy-status { margin: 12px 0 0; color: #f3b4d0; font-size: 13px; line-height: 1.6; }
.copy-status:empty { margin: 0; }
.copy-text { display: grid; gap: 8px; margin-top: 12px; color: #a4adba; font-size: 13px; }
textarea { width: 100%; min-height: 88px; padding: 12px; resize: vertical; border: 1px solid #694254; border-radius: 8px; background: #101319; color: #e8ebf1; font: inherit; line-height: 1.7; }
h2 { margin: 0; font-size: 22px; }
h2 span { margin-left: 6px; color: #a4adba; font-size: 14px; font-weight: 500; }
.clear-button { width: auto; min-height: 38px; padding: 8px 12px; border: 1px solid #694254; background: transparent; color: #f3b4d0; font-size: 13px; }
.clear-button:hover:not(:disabled) { background: #302331; }
.clear-button:disabled { background: transparent; color: #87909e; border-color: #343c49; }
.results-list { margin: 20px 0 0; padding: 0; list-style: none; }
.results-list li { display: grid; grid-template-columns: minmax(28px, auto) minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 16px 12px; border-bottom: 1px solid #343c49; }
.results-list li.latest { border-radius: 9px; border-bottom-color: transparent; background: rgb(237 139 181 / 9%); }
.draw-number { color: #a4adba; font-size: 13px; font-variant-numeric: tabular-nums; }
.song-title { min-width: 0; overflow-wrap: anywhere; line-height: 1.6; }
.badges { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 13px; }
.badges span, .badges strong { padding: 6px 10px; border-radius: 8px; background: #302331; color: #f3b4d0; }
.placeholder { color: #a4adba; line-height: 1.7; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 520px) {
.panel, .result { padding: 20px; }
.results-list li { grid-template-columns: minmax(24px, auto) minmax(0, 1fr); gap: 8px; }
.badges { grid-column: 2; }
}
</style>
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import ToolsPage from '../+page.svelte';
</script>
<ToolsPage />