feat: add standardized team comparison
Publish GitHub Pages output / publish (push) Successful in 42s

This commit is contained in:
dal4segno committed 2026-09-30 18:56:49 +09:00
1 parent d35ee95843
commit 115dc33937
13 files changed
+605 -5

No files matched your search

+1
View File
@@ -34,6 +34,7 @@
<button class="menu-toggle" type="button" aria-controls="primary-navigation" aria-expanded={menuOpen} on:click={() => menuOpen = !menuOpen}>{menuOpen ? '메뉴 닫기' : '전체 메뉴'}</button>
<nav id="primary-navigation" class:open={menuOpen} aria-label="주요 메뉴">
<a href="/cards" aria-current={$page.url.pathname.startsWith('/cards') ? 'page' : undefined}>카드 목록</a>
<a href="/compare" aria-current={$page.url.pathname.startsWith('/compare') ? 'page' : undefined}>편성 비교</a>
<a href="/analysis" aria-current={$page.url.pathname.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
<a href="/rating" aria-current={$page.url.pathname.startsWith('/rating') ? 'page' : undefined}>리더 레이팅</a>
<a href="/combination" aria-current={$page.url.pathname.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
+246
View File
@@ -0,0 +1,246 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import SongPicker from '$lib/components/SongPicker.svelte';
import ComparisonTeamEditor from '$lib/components/ComparisonTeamEditor.svelte';
import cardSummary from '$lib/data/cards-summary.json';
import boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json';
import catalog from '$lib/data/exact-chart-catalog.json';
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { difficulties, difficultyLabel, type DifficultyKey } from '$lib/data/difficulty.ts';
import { comparisonDelta, prepareComparisonTeam, type ComparisonTeam } from '$lib/engine/team-comparison.ts';
import { memoryParameterPermil } from '$lib/engine/memory.ts';
import type { ExactOptimizationResult, ExactSongChart } from '$lib/engine/types.ts';
import type { GrowthSnapshot } from '$lib/models/analysis-history.ts';
import { createUserData } from '$lib/models/user-data.ts';
import { defaultPreferences } from '$lib/models/default-preferences.ts';
import { parseUserData, USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage.ts';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
import { COMPARISON_STORAGE_KEY, parseSavedComparison, type SavedComparison } from '$lib/storage/team-comparison.ts';
const cards = cardSummary.cards.filter((card) => card.rarity === 5);
const unique = [...new Map(cards.map((card) => [card.characterId, card])).values()].slice(0, 5);
const initialTeam = (): ComparisonTeam => ({ cardIds: unique.map((card) => card.id), awakenings: unique.map(() => 0), leaderCharacterId: unique[0].characterId, leaderOutfitCardId: unique[0].id });
let teamA = initialTeam();
let teamB = initialTeam();
let growth: GrowthSnapshot = createUserData(defaultPreferences());
let clock = createInitialReleaseClock($page.data?.serverNow);
let releaseNow = clock.now();
$: charts = releasedSongCharts(catalog.charts, releaseNow);
let songId = releasedSongCharts(catalog.charts, releaseNow)[0]?.songId ?? '';
let difficulty: DifficultyKey = 'expert';
$: selectedChart = charts.find((chart) => chart.songId === songId && chart.difficulty === difficulty);
let ready = false;
let running = false;
let error = '';
let storageNotice = '';
let comparisonNotice = '';
let runId = 0;
let progress = ['대기', '대기'];
type Result = { score: ExactOptimizationResult; overallPower: number; maximumScoreProbability: number };
let results: Result[] | null = null;
const jobs = new Set<() => void>();
$: inputKey = JSON.stringify({ songId, difficulty, teamA, teamB, growth });
let lastInputKey = '';
$: if (inputKey !== lastInputKey) { lastInputKey = inputKey; invalidate(); }
$: metrics = results ? [
{ label: '평균 점수', a: results[0].score.expectedScore, b: results[1].score.expectedScore, unit: '점' },
{ label: '최고 점수', a: results[0].score.theoreticalMaxScore, b: results[1].score.theoreticalMaxScore, unit: '점' },
{ label: '종합력', a: results[0].overallPower, b: results[1].overallPower, unit: '' }
] : [];
function invalidate() {
runId++;
for (const cancel of jobs) cancel();
running = false; results = null; error = '';
}
function loadGrowth() {
try {
const text = localStorage.getItem(USER_DATA_STORAGE_KEY);
const data = text ? parseUserData(text, defaultPreferences()) : createUserData(defaultPreferences());
growth = { collection: data.collection, memberBoards: data.memberBoards, boardCards: data.boardCards, memoryCount: Math.min(data.memoryCount, memorySummary.maxCount), memberPowerUpPercent: data.memberPowerUpPercent };
storageNotice = text ? '' : '저장된 사용자 데이터가 없어 메모리와 멤버 종합력 UP은 0으로 적용합니다.';
} catch { storageNotice = '사용자 데이터를 읽지 못했습니다. 사용자 데이터 페이지에서 확인해 주세요.'; ready = false; return; }
ready = true;
}
onMount(() => {
let mounted = true;
loadGrowth();
const owned = [...new Map(cards.filter((card) => growth.collection[card.id]?.owned).map((card) => [card.characterId, card])).values()].slice(0, 5);
if (owned.length === 5) {
teamA = { cardIds: owned.map((card) => card.id), awakenings: owned.map((card) => growth.collection[card.id]?.awakening ?? 0), leaderCharacterId: owned[0].characterId, leaderOutfitCardId: owned[0].id };
teamB = structuredClone(teamA);
}
try {
const savedText = localStorage.getItem(COMPARISON_STORAGE_KEY);
if (savedText) {
const saved = parseSavedComparison(savedText, cardSummary.cards);
teamA = saved.teamA;
teamB = saved.teamB;
if (releasedSongCharts(catalog.charts, clock.now()).some((chart) => chart.songId === saved.songId && chart.difficulty === saved.difficulty)) {
songId = saved.songId;
difficulty = saved.difficulty;
} else comparisonNotice = '저장된 악곡·난이도를 사용할 수 없어 편성만 복원했습니다.';
}
} catch { comparisonNotice = '저장된 비교 조건을 불러오지 못했습니다. 편성을 다시 선택해 주세요.'; }
void synchronizeReleaseClock().then((value) => { if (mounted) { clock = value; releaseNow = clock.now(); } });
const timer = window.setInterval(() => releaseNow = clock.now(), 1000);
const onStorage = (event: StorageEvent) => { if (event.key === USER_DATA_STORAGE_KEY || event.key === null) loadGrowth(); };
window.addEventListener('storage', onStorage);
return () => { mounted = false; invalidate(); window.clearInterval(timer); window.removeEventListener('storage', onStorage); };
});
function optimize(context: ReturnType<typeof prepareComparisonTeam>, chart: ExactSongChart, index: number): Promise<Result> {
return new Promise((resolve, reject) => {
const worker = createScoreOptimizerWorker();
const finish = () => { worker.terminate(); jobs.delete(cancel); };
const cancel = () => { finish(); reject(new Error('비교를 취소했습니다.')); };
jobs.add(cancel);
worker.onerror = () => { finish(); reject(new Error('비교 계산을 실행하지 못했습니다. 다시 시도해 주세요.')); };
worker.onmessage = (event) => {
if (event.data.type === 'progress') {
const { evaluatedCandidates, totalCandidates } = event.data.progress;
progress[index] = `${Math.round(evaluatedCandidates / Math.max(1, totalCandidates) * 100)}%`;
} else if (event.data.type === 'probability-start') {
progress[index] = '최고점 확률 계산 중';
} else if (event.data.type === 'result') {
finish(); progress[index] = '완료';
resolve({ score: event.data.result, overallPower: context.overallPower, maximumScoreProbability: event.data.maximumScoreProbability });
} else if (event.data.type === 'error') { finish(); reject(new Error(event.data.message)); }
};
worker.postMessage({ type: 'optimize-exact', ...context, chart, includeMaximumScoreProbability: true });
});
}
async function compare() {
invalidate();
const token = runId;
if (!selectedChart) { error = '선택한 악곡·난이도의 채보가 없습니다.'; return; }
running = true; progress = ['채보 로딩', '채보 로딩'];
try {
const memory = memoryParameterPermil(growth.memoryCount, memorySummary.effects);
const contexts = [teamA, teamB].map((team) => prepareComparisonTeam(team, cardSummary.cards, boardSummary, growth, selectedChart!, memory));
const snapshot: SavedComparison = structuredClone({ schemaVersion: 1, songId, difficulty, teamA, teamB });
const chart = await loadExactSongChart(selectedChart.path);
if (token !== runId) return;
progress = ['계산 중', '계산 중'];
const computed = await Promise.all(contexts.map((context, index) => optimize(context, chart, index)));
if (token === runId) {
results = computed;
try {
localStorage.setItem(COMPARISON_STORAGE_KEY, JSON.stringify(snapshot));
comparisonNotice = '';
} catch { comparisonNotice = '비교는 완료했지만 조건을 저장하지 못했습니다. 브라우저 저장 공간 설정을 확인해 주세요.'; }
}
} catch (cause) {
if (token === runId) { for (const cancel of jobs) cancel(); error = cause instanceof Error ? cause.message : '비교하지 못했습니다.'; }
} finally { if (token === runId) running = false; }
}
const number = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
function probability(value: number) {
return value > 0 && value < 0.0001 ? '0.01% 미만' : `${(value * 100).toFixed(2)}%`;
}
</script>
<svelte:head><title>편성 비교 | 홀로도리 계산기</title><meta name="description" content="같은 악곡과 난이도에서 두 리더·편성의 평균 점수와 최고 점수를 비교합니다."></svelte:head>
<main>
<header class="page-header">
<h1>편성 비교</h1>
<p class="page-description">같은 악곡·난이도에서 두 리더와 편성이 얼마나 차이 나는지 확인하세요.</p>
</header>
<section class="panel" aria-label="공통 비교 조건">
<div class="song-fields">
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={songId} showSelectedTitle={false} /></div>
<label class="difficulty">난이도<select bind:value={difficulty}>{#each difficulties as value}<option value={value}>{difficultyLabel(value)}</option>{/each}</select></label>
</div>
{#if !selectedChart}<p role="alert">이 악곡·난이도는 공개된 채보가 없습니다. 다른 조건을 선택해 주세요.</p>{/if}
</section>
<div class="actions"><button on:click={() => teamB = structuredClone(teamA)}>A → B 복사</button><button on:click={() => { const old = teamA; teamA = teamB; teamB = old; }}>A ↔ B 교환</button></div>
<div class="teams"><ComparisonTeamEditor label="A" bind:team={teamA} collection={growth.collection} /><ComparisonTeamEditor label="B" bind:team={teamB} collection={growth.collection} /></div>
<div class="actions"><button class="primary" on:click={compare} disabled={!ready || !selectedChart || running}>{running ? '비교 계산 중…' : '두 편성 비교'}</button>{#if running}<button on:click={invalidate}>취소</button>{/if}</div>
{#if running}<p role="status">편성 A: {progress[0]} · 편성 B: {progress[1]}</p>{/if}
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if comparisonNotice}<p class="hint" role="status">{comparisonNotice}</p>{/if}
{#if results}
{@const probabilityDelta = (results[1].maximumScoreProbability - results[0].maximumScoreProbability) * 100}
<section class="panel result" aria-label="비교 결과" aria-live="polite">
<h2>비교 결과</h2><p>{selectedChart?.title} · {difficultyLabel(difficulty)}</p>
<div class="table-wrap"><table><thead><tr><th>비교 항목</th><th class="team-a">편성 A</th><th class="team-b">편성 B</th><th>차이</th></tr></thead><tbody>
{#each metrics as metric}
{@const delta = comparisonDelta(Math.min(metric.a, metric.b), Math.max(metric.a, metric.b))}
<tr><th>{metric.label}</th><td>{number(metric.a)}{metric.unit}</td><td>{number(metric.b)}{metric.unit}</td>
<td class="difference">
{#if delta.difference === 0}동일
{:else}
<strong class:team-a={metric.a > metric.b} class:team-b={metric.b > metric.a}>{metric.a > metric.b ? 'A' : 'B'}</strong>가
{#if delta.percent === null}<strong>{number(delta.difference)}{metric.unit}</strong> 우위 (비율 계산 불가)
{:else}<strong>{delta.percent < 0.01 ? '0.01% 미만' : `${delta.percent.toFixed(2)}%`}</strong> 우위{/if}
{/if}
</td>
</tr>
{/each}
<tr><th>최고점 달성 확률</th><td>{probability(results[0].maximumScoreProbability)}</td><td>{probability(results[1].maximumScoreProbability)}</td>
<td class="difference">
{#if probabilityDelta === 0}동일
{:else}<strong class:team-a={probabilityDelta < 0} class:team-b={probabilityDelta > 0}>{probabilityDelta > 0 ? 'B' : 'A'}</strong>가 <strong>{Math.abs(probabilityDelta) < 0.01 ? '0.01%p' : `${Math.abs(probabilityDelta).toFixed(2)}%p`}</strong>{Math.abs(probabilityDelta) < 0.01 ? ' 미만' : ''} 높음{/if}
</td>
</tr>
</tbody></table></div>
<p class="hint">우위 비율은 값이 낮은 편성을 기준으로 계산합니다. 최고점 달성 확률의 차이는 %p로 표시합니다.</p>
<p class="hint">해당 결과는 추정치며, 실제 배치 분석 결과와 다를 수 있습니다.</p>
</section>
{/if}
<section class="panel comparison-rules" aria-labelledby="comparison-rules-title">
<h2 id="comparison-rules-title">비교 기준</h2>
<ul>
<li>리더의 빨간 노드 전체, 편성 멤버의 파란 노드 전체를 개방한 상태로 계산합니다.</li>
<li>저장된 보드 개방 상태와 리더·멤버의 연결 노드 증폭 효과는 적용하지 않습니다.</li>
<li>기수 보너스는 카드마다 900, 가창 보너스는 10%로 고정합니다.</li>
<li>메모리 {growth.memoryCount}개 · 멤버 종합력 UP {growth.memberPowerUpPercent}%는 저장된 값을 두 편성에 동일하게 적용합니다.</li>
<li>5성 카드 · 최대 레벨 · ALL PERFECT 기준이며, 개화는 편성별 입력값을 사용합니다.</li>
<li>마지막으로 비교를 완료한 두 편성·리더 의상·개화·악곡·난이도는 이 브라우저에 저장되어 다음 방문에 복원됩니다.</li>
<li>입력 순서와 관계없이 스페셜 순서·쿨타임을 최고 점수 기준으로 최적화합니다. 평균 점수는 이 최적 배치에서 액티브 발동 확률을 반영한 기대값입니다.</li>
<li>최고점 달성 확률은 각 편성의 최적 배치에서 해당 편성의 최고 점수에 도달할 확률입니다. 액티브 발동이 서로 독립이라는 가정으로 계산하며, 확률 차이는 %p로 표시합니다.</li>
</ul>
<p>실제 점수는 판정과 액티브 발동 결과에 따라 달라집니다.</p>
{#if storageNotice}<p role="status">{storageNotice}</p>{/if}
</section>
</main>
<style>
main { --team-a-color: #8bc9f5; --team-b-color: #f3a5c5; width: min(1160px, calc(100% - var(--page-gutter))); margin: auto; padding: var(--page-content-top) 0 80px; }
.page-header { margin-bottom: var(--page-header-bottom); }
h1 { margin: 0; color: #f3f5f8; font-size: var(--page-title-size); line-height: 1.15; letter-spacing: -.045em; }
.page-description { margin: 10px 0 0; color: #929cab; font-size: var(--page-description-size); line-height: 1.6; }
p { color: #a4adba; line-height: 1.7; }
.panel { padding: 24px; background: #151a22; border: 1px solid #343c49; border-radius: 16px; }
.song-fields { display: grid; grid-template-columns: minmax(0, 4fr) minmax(140px, .85fr); align-items: start; gap: 12px; }
.song-picker-field { min-width: 0; }
.difficulty { display: grid; gap: 7px; min-width: 0; color: #8f98a8; font-size: 14px; font-weight: 750; }
.difficulty select { height: 42px; min-height: 42px; padding: 0 12px; border-color: #343c49; background: #11151b; }
select, button { min-height: 44px; padding: 10px 16px; border: 1px solid #414b5b; border-radius: 9px; background: #202632; color: #e8ebf1; font: inherit; }
button { cursor: pointer; font-weight: 750; } button:disabled { opacity: .5; cursor: not-allowed; }
:is(button, select):focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
.actions { display: flex; gap: 10px; margin: 20px 0; flex-wrap: wrap; }
.primary { background: #ed8bb5; color: #21121a; min-width: 220px; }
.teams { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; }
.hint { font-size: 13px; color: #a4adba; } .error { color: #ffa4a4; }
.comparison-rules { margin-top: 28px; color: #8f98a8; font-size: 12px; line-height: 1.6; }
.comparison-rules h2 { margin: 0 0 12px; color: #b9c1cd; font-size: 15px; }
.comparison-rules ul { margin: 0; padding-left: 18px; }
.comparison-rules li { padding: 3px 0; line-height: 1.6; }
.comparison-rules p { margin: 10px 0 0; color: inherit; }
.result { margin-top: 24px; }
.table-wrap { overflow-x: auto; } table { border-collapse: collapse; width: 100%; text-align: left; font-variant-numeric: tabular-nums; }
td, th { padding: 16px 12px; border-bottom: 1px solid #343c49; white-space: nowrap; }
.difference { color: #a4adba; }
.difference strong { color: #f3f5f8; font-weight: 750; }
.team-a, .difference strong.team-a { color: var(--team-a-color); }
.team-b, .difference strong.team-b { color: var(--team-b-color); }
th.team-a { background: rgb(139 201 245 / 7%); }
th.team-b { background: rgb(243 165 197 / 7%); }
@media (max-width: 760px) { .teams { grid-template-columns: 1fr; } .panel { padding: 18px; } }
@media (max-width: 520px) { .song-fields { grid-template-columns: minmax(0, 1fr); } }
</style>
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
redirect(307, '/compare');
}