feat: streamline search results and open captured analyses
Publish GitHub Pages output / publish (push) Successful in 54s

This commit is contained in:
2026-09-09 19:15:25 +09:00
parent 28035110f9
commit d26b9ca884
8 changed files with 365 additions and 53 deletions
+27 -8
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { difficultyLabel } from '$lib/data/difficulty.ts';
import { ANALYSIS_TRANSFER_KEY, type AnalysisTransfer } from '$lib/models/analysis-transfer.ts';
import type { AnalysisCapture } from '$lib/models/analysis-history.ts';
import { onDestroy, onMount } from 'svelte';
import type catalog from '$lib/data/exact-chart-catalog.json';
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
@@ -9,9 +11,9 @@
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
type Chart = (typeof catalog.charts)[number];
type Context = { cards: OptimizerCard[]; overallPower: number; directSongScoreBonusPercent: number };
type Context = { cards: OptimizerCard[]; overallPower: number; directSongScoreBonusPercent: number; capture: AnalysisCapture };
type Evaluation = { result: ExactOptimizationResult; achievementProbabilities: ScoreAchievementProbabilities };
type Row = Chart & Evaluation & { overallPower: number; theoreticalMaxScore: number; expectedScore: number };
type Row = Chart & Evaluation & { transfer: AnalysisTransfer; overallPower: number; theoreticalMaxScore: number; expectedScore: number };
export let charts: Chart[];
export let leaderId: string;
export let leaderName: string;
@@ -19,7 +21,6 @@
export let inputKey: string;
export let disabled = false;
export let getContext: (chart: Chart) => Context;
export let onSelect: (chart: Chart, result: ExactOptimizationResult, overallPower: number, probabilities: ScoreAchievementProbabilities) => void;
const difficulty = 'all';
let running = false;
@@ -30,6 +31,7 @@
let reportedCores = 1;
let activeJobs: Record<number, { title: string; fraction: number }> = {};
let message = '';
let transferError = '';
let failures: string[] = [];
let snapshotKey = '';
let snapshotLeader = '';
@@ -128,6 +130,8 @@
});
if (id !== runId) return;
rows = [...rows, { ...summary, result, achievementProbabilities, overallPower: context.overallPower,
transfer: { schemaVersion: 1, source: 'rating', capture: context.capture, cards: context.cards, chart,
result, achievementProbabilities, directSongScoreBonusPercent: context.directSongScoreBonusPercent },
theoreticalMaxScore: result.theoreticalMaxScore, expectedScore: result.expectedScore }];
} catch (error) {
if (id !== runId) return;
@@ -159,6 +163,20 @@
}
const format = (value: number) => Math.round(value).toLocaleString('ko-KR');
function openInAnalysis(event: MouseEvent, transfer: AnalysisTransfer): void {
event.preventDefault();
transferError = '';
try {
sessionStorage.setItem(ANALYSIS_TRANSFER_KEY, JSON.stringify(transfer));
const opened = window.open((event.currentTarget as HTMLAnchorElement).href, '_blank');
if (!opened) throw new Error('새 탭이 차단되었습니다. 팝업을 허용한 뒤 다시 열어 주세요.');
opened.opener = null;
} catch (error) {
transferError = error instanceof Error ? error.message : '배치 분석을 열지 못했습니다.';
} finally {
try { sessionStorage.removeItem(ANALYSIS_TRANSFER_KEY); } catch { /* Storage may be blocked. */ }
}
}
function probabilityLabel(value: number): string {
if (value <= 0) return '0%';
if (value < 0.000001) return '<0.0001%';
@@ -173,6 +191,7 @@
}
</script>
<section class="ranking" aria-labelledby="leader-ranking-title">
{#if transferError}<p role="alert">{transferError}</p>{/if}
<div class="heading">
<div class="section-heading"><span class="section-number">02</span><div><h2 id="leader-ranking-title">리더 레이팅 곡 추천</h2><p>지금 편성으로 도전할 곡을 찾아보세요.</p></div></div>
</div>
@@ -242,7 +261,7 @@
<div title={`${format(Math.ceil(row.theoreticalMaxScore * .99))}점 이상 달성 확률`}><span>99% 이상</span><strong>{probabilityLabel(row.achievementProbabilities.atLeast99Percent)}</strong></div>
<div title={`${format(Math.ceil(row.theoreticalMaxScore * .95))}점 이상 달성 확률`}><span>95% 이상</span><strong>{probabilityLabel(row.achievementProbabilities.atLeast95Percent)}</strong></div>
</div></td>
<td class="row-action"><button type="button" disabled={stale || disabled || running} on:click={() => onSelect(row, row.result, row.overallPower, row.achievementProbabilities)}>배치 보기 <span aria-hidden="true">→</span></button></td>
<td class="row-action"><a href="/analysis" on:click={(event) => openInAnalysis(event, row.transfer)}>배치 분석에서 보기 <span aria-hidden="true">↗</span></a></td>
</tr>{/each}</tbody>
</table>
</div>
@@ -376,8 +395,8 @@
.achievement-grid span { color: #9690a6; font-size: 10px; }
.achievement-grid strong { color: #cbb4db; font-size: 12px; font-weight: 650; }
.achievement-grid > div:first-child strong { color: #f1b0ce; }
.row-action button { padding: 9px 11px; background: transparent; white-space: nowrap; font-size: 11px; }
.row-action button > span { margin-left: 8px; color: #bd8ea8; }
.row-action a { display: inline-block; padding: 9px 11px; color: inherit; background: transparent; white-space: nowrap; font-size: 11px; }
.row-action a > span { margin-left: 8px; color: #bd8ea8; }
.mobile-label { display: none; }
.list-footer { justify-content: space-between; gap: 12px; margin-top: 15px; }
.range-label { color: #c4bdcd; font-size: 11px; }
@@ -452,8 +471,8 @@
.average-score { grid-column: 2; grid-row: 3; text-align: left; font-size: 11px; }
.mobile-label { display: inline-block; min-width: 48px; margin-right: 6px; color: #828e9f; font-size: 10px; }
.row-action { grid-column: 3; grid-row: 2 / 4; align-self: end; }
.row-action button { padding: 7px 8px; font-size: 10px; }
.row-action button > span { display: none; }
.row-action a { padding: 7px 8px; font-size: 10px; }
.row-action a > span { display: none; }
.probability-cell { grid-column: 1 / -1; grid-row: 4; padding-top: 11px; margin-top: 5px; border-top: 1px solid #3c3342; }
.achievement-grid { gap: 12px; text-align: center; }
.achievement-grid > div { gap: 5px; }
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { combinationAnalysisSettings, parseAnalysisTransfer, transferredAnalysisKey, type AnalysisTransfer } from '../models/analysis-transfer.ts';
import { calculateExactScore } from './score.ts';
import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts';
import type { GrowthSnapshot } from '../models/analysis-history.ts';
const growth: GrowthSnapshot = { collection: { e: { owned: true, awakening: 5 } },
memberBoards: { e: ['G1'] }, boardCards: {}, memoryCount: 30, memberPowerUpPercent: 4.37 };
const state = { songId: 'song', difficulty: 'expert' as const, fixedCardIds: ['c', 'd', 'a', 'b'],
fixedAwakenings: [5, 3, 4, 4], fixedCooldownReductions: [null, 12, null, 4] as (null | 12 | 4)[],
fixedAllBlueNodes: [false, true, false, false], fixedGenerationBonusOverrides: [null, 0, 900, null],
awakening: 4, candidateGenerationBonus: 450, songScoreBonusOverride: 10,
leaderCharacterId: 'leader', leaderOutfitCardId: 'outfit' };
test('조합의 표시 순서로 바뀌어도 고정 카드 조건과 자동 후보 보드·보유 개화를 정확히 전달한다', () => {
const settings = combinationAnalysisSettings(state, ['a', 'b', 'c', 'd', 'e'], growth);
assert.deepEqual(settings.awakenings, [4, 4, 5, 3, 5]);
assert.deepEqual(settings.fixedCooldownReductions, [null, 4, null, 12, null]);
assert.deepEqual(settings.allBlueNodes, [false, false, false, true, true]);
assert.deepEqual(settings.generationBonusOverrides, [900, null, null, 0, 450]);
assert.equal(settings.leaderOutfitCardId, 'outfit');
assert.equal(settings.songScoreBonusOverride, 10);
assert.deepEqual(growth.memberBoards, { e: ['G1'] });
});
test('전달 결과는 재탐색 없이 점수·확률을 유지하고 원래 카드·채보로 시뮬레이션한다', () => {
const cards = ['a', 'b', 'c', 'd', 'e'].map((id, i) => ({ id, name: id,
active: { cooldownSeconds: 2 + i, durationSeconds: 1, scoreUpPercent: 100, probabilityPercent: 55 },
special: { durationSeconds: 1, scoreUpPercent: 100 }, allowedCooldownReductions: [0] as const }));
const chart = { songId: 'song', difficulty: 'expert', durationSeconds: 8, scoreRatio: 1,
specialStartSeconds: [1, 2, 3, 4, 5], notes: Array.from({ length: 8 }, (_, timeSeconds) => ({timeSeconds, type: 'regular' as const, isFlick: false})) };
const result = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100, directSongScoreBonusPercent: 10 });
const capture = { settings: combinationAnalysisSettings(state, cards.map(c => c.id), growth), growth,
title: 'song', durationSeconds: 8, overallPower: 100, analyzedAt: '2026-09-09T09:00:00Z' };
const transfer: AnalysisTransfer = { schemaVersion: 1, capture, result, cards, chart, directSongScoreBonusPercent: 10,
achievementProbabilities: { maximum: 0.1, atLeast99Percent: 0.2, atLeast95Percent: 0.4, samples: 25000 } };
const loaded = parseAnalysisTransfer(JSON.stringify(transfer));
assert.deepEqual(loaded, transfer);
const score = calculateExactScore(loaded.result.order.map(id => loaded.cards.find(c => c.id === id)!), loaded.result.cooldownReductions, loaded.chart,
{ perfectNoteScore: loaded.result.perfectNoteScore, directSongScoreBonusPercent: loaded.directSongScoreBonusPercent });
assert.equal(score.theoreticalMaxScore, result.theoreticalMaxScore);
assert.equal(score.expectedScore, result.expectedScore);
const originalKey = transferredAnalysisKey(loaded.capture);
loaded.capture.growth.memoryCount += 1;
assert.notEqual(transferredAnalysisKey(loaded.capture), originalKey);
assert.equal(growth.memoryCount, 30);
assert.throws(() => parseAnalysisTransfer(JSON.stringify({ ...transfer, cards: [] })));
assert.throws(() => parseAnalysisTransfer(JSON.stringify({ ...transfer, chart: { ...chart, songId: 'another song' } })));
});
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { createContext, runInContext } from 'node:vm';
import ts from 'typescript';
const source = readFileSync(new URL('../workers/score-optimizer.worker.ts', import.meta.url), 'utf8');
const code = ts.transpileModule(source.replace(/^import .*;\r?\n/gm, '').replace('export {};', ''), {
compilerOptions: { target: ts.ScriptTarget.ES2022 }
}).outputText;
test('조합 워커는 요청한 결과 수를 점수순으로 반환하고 미지정·잘못된 값은 10개로 처리한다', () => {
for (const [limit, expected] of [[undefined, 10], [10, 10], [20, 20], [30, 30], [50, 50], [0, 10]]) {
const messages: any[] = [];
const context = createContext({
self: { postMessage: (message: unknown) => messages.push(message) },
countOptimizationCandidates: () => 1,
optimizeExactOrderAndCooldowns: (_cards: unknown, _chart: unknown, options: { perfectNoteScore: number }) => ({
theoreticalMaxScore: options.perfectNoteScore, expectedScore: options.perfectNoteScore
})
});
runInContext(code, context);
context.request = { type: 'optimize-combinations', resultLimit: limit, chart: {},
candidates: Array.from({ length: 60 }, (_, i) => ({ cards: [], overallPower: i, perfectNoteScore: i })) };
runInContext('self.onmessage({ data: request });', context);
const result = messages.at(-1);
assert.equal(result.type, 'combination-result');
assert.equal(result.rankings.length, expected);
assert.deepEqual(Array.from(result.rankings, (row: any) => row.result.theoreticalMaxScore),
Array.from({ length: expected! }, (_, i) => 59 - i));
}
});
+63
View File
@@ -0,0 +1,63 @@
import type { AnalysisCapture, AnalysisSettings, GrowthSnapshot } from './analysis-history.ts';
import type { ExactOptimizationResult, ExactSongChart, OptimizerCard, ScoreAchievementProbabilities } from '../engine/types.ts';
import { resolveCandidateAwakening } from '../engine/combination-search.ts';
export const ANALYSIS_TRANSFER_KEY = 'holodori.analysis-transfer.v1';
export type AnalysisTransfer = {
schemaVersion: 1;
source?: 'combination' | 'rating';
capture: AnalysisCapture;
result: ExactOptimizationResult;
cards: OptimizerCard[];
chart: ExactSongChart;
directSongScoreBonusPercent: number;
achievementProbabilities?: ScoreAchievementProbabilities;
};
export type CombinationConditions = Pick<AnalysisSettings, 'songId' | 'difficulty' | 'leaderCharacterId' | 'leaderOutfitCardId' | 'songScoreBonusOverride'> & {
fixedCardIds: string[]; fixedAwakenings?: number[];
fixedCooldownReductions?: AnalysisSettings['fixedCooldownReductions'];
fixedAllBlueNodes?: boolean[]; fixedGenerationBonusOverrides?: (number | null)[];
awakening: number; candidateGenerationBonus?: number | null;
};
export function combinationAnalysisSettings(state: CombinationConditions, cardIds: readonly string[], growth: GrowthSnapshot): AnalysisSettings {
const slots = cardIds.map(id => state.fixedCardIds.indexOf(id));
return {
songId: state.songId, difficulty: state.difficulty, cardIds: [...cardIds],
awakenings: cardIds.map((id, i) => slots[i] >= 0 ? state.fixedAwakenings?.[slots[i]] ?? 0
: resolveCandidateAwakening(state.awakening, Boolean(growth.collection[id]?.owned), growth.collection[id]?.awakening ?? 5)),
fixedCooldownReductions: slots.map(slot => slot < 0 ? null : state.fixedCooldownReductions?.[slot] ?? null),
allBlueNodes: slots.map(slot => slot < 0 || state.fixedAllBlueNodes?.[slot] === true),
generationBonusOverrides: slots.map(slot => slot < 0 ? state.candidateGenerationBonus ?? null : state.fixedGenerationBonusOverrides?.[slot] ?? null),
songScoreBonusOverride: state.songScoreBonusOverride ?? null,
leaderCharacterId: state.leaderCharacterId, leaderOutfitCardId: state.leaderOutfitCardId
};
}
// Match the analysis page's key so a transferred result can flag newer growth data.
export function transferredAnalysisKey({ settings: s, growth: g }: AnalysisCapture): string {
return JSON.stringify({ songId: s.songId, difficulty: s.difficulty, input: JSON.stringify({
selectedCardIds: s.cardIds, selectedAwakenings: s.awakenings,
fixedCooldownReductions: s.fixedCooldownReductions, allBlueNodes: s.allBlueNodes,
generationBonusOverrides: s.generationBonusOverrides, songScoreBonusOverride: s.songScoreBonusOverride,
leaderCharacterId: s.leaderCharacterId, leaderOutfitCardId: s.leaderOutfitCardId,
collection: g.collection, memberBoards: g.memberBoards, boardCards: g.boardCards,
memoryCount: g.memoryCount, memberPowerUpPercent: g.memberPowerUpPercent
}) });
}
export function parseAnalysisTransfer(raw: string): AnalysisTransfer {
const value = JSON.parse(raw) as AnalysisTransfer;
const ids = value?.capture?.settings?.cardIds;
if (value?.schemaVersion !== 1 || !Array.isArray(ids) || ids.length !== 5 || new Set(ids).size !== 5 ||
!value.capture.growth || !Array.isArray(value.cards) || value.cards.length !== 5 ||
!ids.every(id => value.cards.some(card => card.id === id)) ||
!Array.isArray(value.result?.order) || value.result.order.length !== 5 || new Set(value.result.order).size !== 5 ||
!value.result.order.every(id => ids.includes(id)) || !Array.isArray(value.result.notes) ||
!Array.isArray(value.chart?.notes) || value.chart.songId !== value.capture.settings.songId ||
value.chart.difficulty !== value.capture.settings.difficulty || !Number.isFinite(value.directSongScoreBonusPercent)) {
throw new Error('전달된 분석 결과를 읽지 못했습니다. 원래 결과 목록에서 다시 열어 주세요.');
}
return value;
}
+3 -1
View File
@@ -24,6 +24,7 @@ type CombinationCandidate = {
type CombinationRequest = {
type: 'optimize-combinations';
resultLimit?: number;
candidates: CombinationCandidate[];
chart: ExactSongChart;
};
@@ -145,7 +146,8 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
}
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
rankings.sort(compareRankings);
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, 10) });
const resultLimit = [10, 20, 30, 50].includes(event.data.resultLimit ?? 10) ? event.data.resultLimit ?? 10 : 10;
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, resultLimit) });
} catch (error) {
self.postMessage({
type: 'error',
+49 -20
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { ANALYSIS_TRANSFER_KEY, parseAnalysisTransfer, transferredAnalysisKey } from '$lib/models/analysis-transfer.ts';
import { onMount, tick } from 'svelte';
import LeaderSongRanking from '$lib/components/LeaderSongRanking.svelte';
import { analytics } from '$lib/analytics.ts';
@@ -119,6 +120,8 @@
let pendingSimulationContext: SimulationContext | null = null;
let resultInputKey = '';
let pendingInputKey = '';
let transferredResult = false;
let transferredSource = '조합 탐색';
let simulationBase: ExactOptimizationResult | null = null;
let simulatedResult: ExactOptimizationResult | null = null;
let excludedActiveWindows: SkillWindow[] = [];
@@ -334,11 +337,46 @@
analysisReady = true;
}
if (mode === 'analysis') {
try {
const raw = sessionStorage.getItem(ANALYSIS_TRANSFER_KEY);
if (raw) {
const transfer = parseAnalysisTransfer(raw);
restoreAnalysisState(JSON.stringify({ ...transfer.capture.settings, schemaVersion: 2 }));
// Let hydration finish before mounting the result's reactive timeline.
void tick().then(async () => {
if (!mounted) return;
result = transfer.result;
resultCapture = transfer.capture;
resultInputKey = transferredAnalysisKey(transfer.capture);
achievementProbabilities = transfer.achievementProbabilities;
simulationContext = { cards: transfer.cards, chart: transfer.chart, options: {
perfectNoteScore: transfer.result.perfectNoteScore,
directSongScoreBonusPercent: transfer.directSongScoreBonusPercent
} };
transferredResult = true;
transferredSource = transfer.source === 'rating' ? '리더 레이팅' : '조합 탐색';
await tick();
requestAnimationFrame(() => {
if (!mounted) return;
const heading = document.getElementById('result-title');
heading?.focus({ preventScroll: true });
heading?.scrollIntoView({ behavior: 'instant', block: 'start' });
});
});
sessionStorage.removeItem(ANALYSIS_TRANSFER_KEY);
}
} catch (error) {
errorMessage = error instanceof Error ? error.message : '조합 탐색 결과를 읽지 못했습니다.';
}
}
worker = createScoreOptimizerWorker();
worker.onmessage = (event) => {
if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'result') {
result = event.data.result;
transferredResult = false;
resultCapture = pendingCapture;
resultInputKey = pendingInputKey;
simulationContext = pendingSimulationContext;
@@ -568,24 +606,13 @@
throw new Error('서로 다른 멤버 5명과 성장 설정을 확인해 주세요.');
}
return { cards: context.cards, overallPower: context.unitPower.overallPower,
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent };
}
async function showRatingResult(chart: NonNullable<typeof selectedChart>, rankingResult: ExactOptimizationResult, power: number, probabilities: ScoreAchievementProbabilities) {
ratingResultKey = ratingInputKey;
selectedSongId = chart.songId;
difficulty = chart.difficulty as DifficultyKey;
result = rankingResult;
achievementProbabilities = probabilities;
clearConnectionOptimizationProposal();
await tick();
resultCapture = structuredClone({
settings: presetSnapshot,
growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
title: chart.title, durationSeconds: chart.durationSeconds, overallPower: power,
analyzedAt: new Date().toISOString()
});
document.getElementById('result-title')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent,
capture: structuredClone({
settings: { ...presetSnapshot, songId: chart.songId, difficulty: chart.difficulty as DifficultyKey, songScoreBonusOverride: 10 },
growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
title: chart.title, durationSeconds: chart.durationSeconds, overallPower: context.unitPower.overallPower,
analyzedAt: new Date().toISOString()
}) };
}
function connectionSocketName(memberId: string, socketId: string): string {
@@ -1068,12 +1095,13 @@
{#if mode === 'rating'}
<LeaderSongRanking {charts} leaderId={leaderCharacterId} leaderName={leaderMember?.name ?? ''}
now={releaseNow} inputKey={ratingInputKey} disabled={!analysisReady || running || connectionOptimizationRunning || candidateCount === 0}
getContext={ratingContext} onSelect={showRatingResult} />
getContext={ratingContext} />
{/if}
{#if mode === 'analysis' || result}
<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" tabindex="-1">분석 결과</h2></div></div>
{#if transferredResult}<p class="board-guide">{transferredSource} 당시의 계산 결과입니다. 다시 분석하면 현재 사용자 데이터를 적용합니다.</p>{/if}
{#if resultStale || pendingStale}<p class="stale-warning" role="status">입력 조건 또는 사용자 데이터가 변경되었습니다. {resultStale ? '아래는 이전 조건의 결과입니다. ' : ''}{pendingStale ? '진행 중인 분석은 시작 당시 조건으로 계속 계산합니다. ' : ''}최신 조건을 적용하려면 다시 분석해 주세요.</p>{/if}
{#if mode === 'analysis' && resultCapture}<p class="board-guide"><strong>{resultCapture.title} · {difficultyLabel(resultCapture.settings.difficulty)}</strong> · 분석 당시 종합력 {resultCapture.overallPower.toLocaleString('ko-KR')}</p>{/if}
{#if mode === 'rating' && result}<p class="board-guide"><strong>{selectedChart?.title} · {difficultyLabel(difficulty)}</strong> · 종합력 {overallPower.toLocaleString('ko-KR')} · 가창 보너스 10%</p>{/if}
@@ -1247,6 +1275,7 @@
</main>
<style>
#result-title { scroll-margin-top: 90px; }
.stale-warning { margin: 14px 0; padding: 11px 14px; border: 1px solid #665333; border-radius: 9px; color: #edcf92; background: #292319; font-size: 13px; line-height: 1.6; }
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: var(--page-content-top) 0 80px; }
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: var(--page-header-bottom); }
+126 -23
View File
@@ -11,6 +11,8 @@
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts';
import AnalysisResultView from '$lib/components/AnalysisResultView.svelte';
import { ANALYSIS_TRANSFER_KEY, combinationAnalysisSettings, type AnalysisTransfer } from '$lib/models/analysis-transfer.ts';
import type { GrowthSnapshot } from '$lib/models/analysis-history.ts';
import PresetManager from '$lib/components/PresetManager.svelte';
import SongPicker from '$lib/components/SongPicker.svelte';
import {
@@ -82,6 +84,7 @@
ownedCardsOnly?: boolean;
maxMediumProbabilityCards?: number | null;
maximizeCompute?: boolean;
resultLimit?: number;
leaderCharacterId: string;
leaderOutfitCardId: string;
};
@@ -141,6 +144,8 @@
let songScoreBonusOverride: number | null = 10;
let ownedCardsOnly = false;
let maxMediumProbabilityCards: number | null = null;
let resultLimit = 10;
let activeResultLimit = 10;
let maximizeCompute = false;
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
@@ -170,6 +175,43 @@
let ready = false;
let loadedUserData: string | null | undefined;
let userDataChanged = false;
let searchSnapshot: { conditions: SavedState; growth: GrowthSnapshot; title: string; durationSeconds: number; analyzedAt: string } | null = null;
let baselineTransfer: Omit<AnalysisTransfer, 'result' | 'achievementProbabilities'> | null = null;
let transferError = '';
function openInAnalysis(event: MouseEvent, ranking: DisplayRankedResult): void {
event.preventDefault();
transferError = '';
try {
let transfer: AnalysisTransfer;
if (ranking.isBaseline) {
if (!baselineTransfer) throw new Error('기준 조합 분석이 완료된 뒤 다시 열어 주세요.');
transfer = { ...baselineTransfer, result: ranking.result, achievementProbabilities: ranking.achievementProbabilities };
} else {
if (!searchSnapshot || !activeSearchChart || !ranking.cards) throw new Error('탐색이 완료된 뒤 다시 열어 주세요.');
transfer = {
schemaVersion: 1,
capture: {
settings: combinationAnalysisSettings(searchSnapshot.conditions, ranking.result.formationOrder, searchSnapshot.growth),
growth: searchSnapshot.growth, title: searchSnapshot.title, durationSeconds: searchSnapshot.durationSeconds,
analyzedAt: searchSnapshot.analyzedAt, overallPower: ranking.overallPower
},
result: ranking.result, cards: ranking.cards, chart: activeSearchChart,
directSongScoreBonusPercent: ranking.directSongScoreBonusPercent ?? 0,
achievementProbabilities: ranking.achievementProbabilities
};
}
// A same-origin new tab copies sessionStorage at creation; the search tab stays intact.
sessionStorage.setItem(ANALYSIS_TRANSFER_KEY, JSON.stringify(transfer));
const opened = window.open((event.currentTarget as HTMLAnchorElement).href, '_blank');
if (!opened) throw new Error('새 탭이 차단되었습니다. 팝업을 허용한 뒤 다시 열어 주세요.');
opened.opener = null;
} catch (error) {
transferError = error instanceof Error ? error.message : '배치 분석을 열지 못했습니다.';
} finally {
try { sessionStorage.removeItem(ANALYSIS_TRANSFER_KEY); } catch { /* Storage may be blocked. */ }
}
}
function loadUserData(): void {
const raw = localStorage.getItem(USER_DATA_STORAGE_KEY);
@@ -184,6 +226,7 @@
}
function resetBaseline(): void {
baselineTransfer = null;
baselineWorker?.terminate();
const nextWorker = createScoreOptimizerWorker();
baselineWorker = nextWorker;
@@ -226,7 +269,8 @@
overallPower: baselineOverallPower,
achievementProbabilities: baselineAchievementProbabilities,
isBaseline: true
} : null
} : null,
activeResultLimit
);
$: if (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline();
$: if (!result && rankedResults.length > 0) rankedResults = [];
@@ -262,6 +306,7 @@
songScoreBonusOverride,
ownedCardsOnly,
maxMediumProbabilityCards,
resultLimit,
maximizeCompute,
leaderCharacterId,
leaderOutfitCardId
@@ -280,6 +325,7 @@
songScoreBonusOverride,
ownedCardsOnly,
maxMediumProbabilityCards,
resultLimit,
maximizeCompute,
leaderCharacterId,
leaderOutfitCardId
@@ -416,6 +462,7 @@
? null
: normalizeSongScoreBonusOverride(state.songScoreBonusOverride) ?? 10;
ownedCardsOnly = state.ownedCardsOnly === true;
resultLimit = [10, 20, 30, 50].includes(state.resultLimit ?? 10) ? state.resultLimit ?? 10 : 10;
maxMediumProbabilityCards = typeof state.maxMediumProbabilityCards === 'number' &&
Number.isInteger(state.maxMediumProbabilityCards) && state.maxMediumProbabilityCards >= 0 && state.maxMediumProbabilityCards < 5
? state.maxMediumProbabilityCards : null;
@@ -902,6 +949,13 @@
});
const chart = await loadExactSongChart(chartMetadata.path);
if (baselineWorker !== targetWorker) return;
baselineTransfer = structuredClone({
schemaVersion: 1,
capture: { settings: state, growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
title: chartMetadata.title, durationSeconds: chartMetadata.durationSeconds, overallPower: power.overallPower,
analyzedAt: new Date().toISOString() },
cards: optimizerCards, chart, directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent
});
baselineOverallPower = power.overallPower;
baselineProgress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(optimizerCards) };
baselineWorker.postMessage({
@@ -951,7 +1005,7 @@
bestByCombination.set(key, ranking);
}
}
rankedResults = Array.from(bestByCombination.values()).sort(compareRankedResults).slice(0, 10);
rankedResults = Array.from(bestByCombination.values()).sort(compareRankedResults).slice(0, activeResultLimit);
result = rankedResults[0]?.result ?? null;
if (rankedResults.length > 0 && activeSearchChart && worker && rankedResults.every((ranking) => ranking.cards)) {
searchPhase = 'probability';
@@ -976,7 +1030,7 @@
exactPass = 'refinement';
exactCombinationCount += refinements.length;
if (maximizeCompute) runParallelExact(refinements, activeSearchChart);
else worker?.postMessage({ type: 'optimize-combinations', candidates: refinements, chart: activeSearchChart });
else worker?.postMessage({ type: 'optimize-combinations', candidates: refinements, chart: activeSearchChart, resultLimit: activeResultLimit });
return;
}
}
@@ -1028,12 +1082,12 @@
if (completedWorkers !== workerCount) return;
completedRankings.sort(compareRankedResults);
parallelWorkers = [];
completeExactPass(completedRankings.slice(0, 10));
completeExactPass(completedRankings.slice(0, activeResultLimit));
}
if (event.data.type === 'error') fail(event.data.message);
};
parallelWorker.onerror = () => fail('병렬 분석 Worker에서 오류가 발생했습니다.');
parallelWorker.postMessage({ type: 'optimize-combinations', candidates, chart });
parallelWorker.postMessage({ type: 'optimize-combinations', candidates, chart, resultLimit: activeResultLimit });
});
}
@@ -1046,6 +1100,7 @@
if (previousUserData !== loadedUserData) resetBaseline();
// Rebuild reactive connection cards and candidate ownership before searching.
await tick();
if (running) return;
} catch {
errorMessage = '사용자 데이터를 읽지 못했습니다. 사용자 데이터 페이지에서 저장 상태를 확인해 주세요.';
return;
@@ -1065,6 +1120,10 @@
return;
}
running = true;
activeResultLimit = resultLimit;
searchSnapshot = structuredClone({ conditions: presetSnapshot,
growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
title: selectedChart.title, durationSeconds: selectedChart.durationSeconds, analyzedAt: new Date().toISOString() });
searchPhase = 'screening';
exactPass = 'primary';
activeSearchChart = null;
@@ -1090,7 +1149,7 @@
activeSearchChart = chart;
searchPhase = 'exact';
if (maximizeCompute) runParallelExact(finalists, chart);
else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart });
else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart, resultLimit: activeResultLimit });
} catch (error) {
errorMessage = error instanceof Error ? error.message : '조합 탐색을 준비하지 못했습니다.';
running = false;
@@ -1141,7 +1200,8 @@
function buildDisplayedRankings(
rankings: readonly RankedResult[],
baseline: DisplayRankedResult | null
baseline: DisplayRankedResult | null,
limit = 10
): DisplayRankedResult[] {
if (rankings.length === 0) return [];
const unique = new Map<string, DisplayRankedResult>();
@@ -1155,7 +1215,7 @@
}
return Array.from(unique.values())
.sort((left, right) => compareRankedResults(left, right) || Number(right.isBaseline) - Number(left.isBaseline))
.slice(0, 10);
.slice(0, limit);
}
</script>
@@ -1203,6 +1263,7 @@
</section>
{/if}
<section class="panel" aria-labelledby="conditions-title">
<fieldset class="search-inputs" disabled={running}>
<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-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
@@ -1288,6 +1349,12 @@
</div>
<p class="hint">기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·가창 보너스 {songScoreBonusOverride === null ? '실제 보드값' : `${songScoreBonusOverride}%`} 조건으로 비교합니다.</p>
<PresetManager storageKey={PRESET_STORAGE_KEY} snapshot={presetSnapshot} disabled={running} on:apply={applyPreset} />
<label class="result-limit">결과 출력 수
<select bind:value={resultLimit} disabled={running} aria-describedby="result-limit-guide">
{#each [10, 20, 30, 50] as count}<option value={count}>상위 {count}개</option>{/each}
</select>
</label>
<p id="result-limit-guide" class="candidate-guide">다음 탐색부터 적용합니다. 결과 수를 늘리면 달성 확률 계산 시간이 늘어납니다. 분석한 조합이 적으면 선택한 수보다 적게 표시됩니다.</p>
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `정밀 분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
<label class="compute-option">
@@ -1297,17 +1364,21 @@
</label>
<p class="compute-warning">탐색 속도는 빨라지지만, 분석 중 브라우저 및 기기 사용이 불편할 수 있습니다.</p>
{#if running}<progress value={searchPhase === 'screening' ? screeningProgress : progress.evaluatedCandidates} max={searchPhase === 'screening' ? 1 : progress.totalCandidates}></progress>{/if}
</fieldset>
</section>
<section class="panel" aria-labelledby="result-title">
<div class="section-title"><div><span>02</span><h2 id="result-title">탐색 결과</h2></div>{#if result}<strong>{screenedCombinationCount.toLocaleString('ko-KR')}개 선별 · {exactCombinationCount}개 정밀 분석 · {comparableToBaseline ? '기준 조합 포함 ' : ''}상위 {displayedRankings.length}개</strong>{/if}</div>
{#if userDataChanged}<p class="baseline-guide" role="status">사용자 데이터가 변경되었습니다. 진행 중인 탐색과 아래 결과는 이전 데이터 기준이며, 다시 탐색하면 최신 데이터를 적용합니다.</p>{/if}
{#if !eventMode}<p class="baseline-guide">상세 점수·그래프·액티브 미발동 비교는 각 결과의 ‘배치 분석에서 보기’를 눌러 새 탭에서 확인할 수 있습니다.</p>{/if}
{#if transferError}<p class="error" role="alert">{transferError}</p>{/if}
{#if displayedRankings.length > 0}
<div class="ranking-list">
{#each displayedRankings as ranking, rank}
<details class:baseline-ranking={ranking.isBaseline} class="ranking-card" open={rank === 0}>
<summary>
<svelte:element this={eventMode ? 'details' : 'article'} class:baseline-ranking={ranking.isBaseline} class="ranking-card" open={eventMode && rank === 0}>
<svelte:element this={eventMode ? 'summary' : 'div'} class="ranking-summary">
<span class="rank-number">{rank + 1}위</span>
<div class="rank-team">
<span class="rank-members">
{#if ranking.isBaseline}<b class="baseline-result-label">기준 조합</b>{/if}
{#each ranking.result.formationOrder as cardId, memberIndex}
@@ -1318,17 +1389,25 @@
· +{displayedCardAdditionalAwakenings(cardId, ranking.isBaseline)}회
{/if}
</small>
<span class="member-name">{resultCardName(cardId)}</span>
<span class="member-name">{cardById.get(cardId)?.name ?? cardId}</span>
{#if cardById.get(cardId)?.versionName}<small class="member-tag">{cardById.get(cardId)?.versionName}</small>{/if}
</span>
{#if memberIndex < ranking.result.formationOrder.length - 1}<i aria-hidden="true">·</i>{/if}
{/each}
</span>
</div>
{#if !eventMode}
<div class="team-meta">
<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>
</div>
{/if}
<span class="rank-metric">
<small>{eventMode ? '평균 이벤트 Pt 지수' : '최고 점수'}</small>
<strong>{eventMode ? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0).toLocaleString('ko-KR') : ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>
{#if eventMode}<i>획득량 +{ranking.eventBonusPercent ?? 0}% · 평균 점수 {ranking.result.expectedScore.toLocaleString('ko-KR')}</i>{/if}
{#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}
<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>
{#if eventMode}<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>
{:else}<small class="expected-score">예상 {ranking.result.expectedScore.toLocaleString('ko-KR')}</small>{/if}
</span>
<span class="rank-probabilities">
<small>최고점 대비 달성 확률</small>
@@ -1336,7 +1415,9 @@
<span><b>99% 이상</b><strong>{probabilityLabel(ranking.achievementProbabilities?.atLeast99Percent)}</strong><i>{scoreThreshold(ranking.result.theoreticalMaxScore, 0.99)}점</i></span>
<span><b>95% 이상</b><strong>{probabilityLabel(ranking.achievementProbabilities?.atLeast95Percent)}</strong><i>{scoreThreshold(ranking.result.theoreticalMaxScore, 0.95)}점</i></span>
</span>
</summary>
{#if !eventMode && !running && (!ranking.isBaseline || !baselineRunning)}<a class="analysis-link" href="/analysis" on:click={(event) => openInAnalysis(event, ranking)}>배치 분석에서 보기 ↗</a>{/if}
</svelte:element>
{#if eventMode}
<div class="ranking-detail">
<AnalysisResultView
result={ranking.result}
@@ -1347,7 +1428,8 @@
showScoreSummary={false}
/>
</div>
</details>
{/if}
</svelte:element>
{/each}
</div>
<p class="awakening-guide">추가 개화는 현재 카드 데이터와 조합의 목표 개화를 비교한 값입니다. 미보유 카드를 처음 얻는 과정도 1회로 계산합니다.</p>
@@ -1360,6 +1442,8 @@
</main>
<style>
.result-limit { max-width: 220px; margin-top: 16px; }
.search-inputs { min-width: 0; margin: 0; padding: 0; border: 0; }
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: var(--page-content-top) 0 80px; }
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; }
@@ -1450,17 +1534,23 @@
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
.ranking-list { display: grid; gap: 10px; margin-top: 18px; }
.ranking-card { overflow: hidden; border: 1px solid #303744; border-radius: 12px; background: #12161c; }
.ranking-card[open] { border-color: #55404f; background: #15151b; }
.ranking-card.baseline-ranking { border-color: #665061; background: #1b171d; }
.ranking-card summary { position: relative; display: grid; grid-template-columns: 60px minmax(0, 1fr) clamp(150px, 12vw, 200px) clamp(280px, 23vw, 360px); align-items: center; gap: 18px; min-height: 80px; padding: 12px 46px 12px 18px; cursor: pointer; list-style: none; }
.ranking-card summary::-webkit-details-marker { display: none; }
.ranking-card summary::after { content: '⌄'; position: absolute; top: 50%; right: 15px; color: #858f9f; font-size: 16px; transform: translateY(-50%); transition: transform .15s; }
.ranking-card[open] summary::after { transform: translateY(-50%) rotate(180deg); }
.ranking-summary { display: grid; grid-template-columns: 60px minmax(0, 1fr) clamp(150px, 12vw, 200px) clamp(280px, 23vw, 360px); align-items: center; gap: 18px; min-height: 80px; padding: 12px 18px; }
details.ranking-card > .ranking-summary { cursor: pointer; }
article.ranking-card > .ranking-summary { grid-template-columns: 48px minmax(0, 1.5fr) max-content 130px minmax(260px, 1fr) max-content; gap: 18px; padding: 14px 18px; }
article.ranking-card .rank-members { align-items: flex-start; }
.rank-team { min-width: 0; }
.team-meta { display: grid; justify-items: start; gap: 6px; color: #9ca5b3; font-size: 12px; }
.team-meta > b { white-space: nowrap; }
.team-meta b { padding: 2px 6px; border-radius: 5px; color: #f0b0c9; background: #30212a; font-size: 10px; }
.team-meta b.complete { color: #9edbb7; background: #1e3028; }
.rank-metric .expected-score { font-size: 12px; font-weight: 500; }
.rank-number { display: grid; place-items: center; width: 48px; height: 34px; border-radius: 8px; color: #171019; background: #ed8bb5; font-size: 14px; font-weight: 900; }
.rank-members { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; color: #dce1e8; font-size: 13px; font-weight: 800; line-height: 1.55; }
.rank-members > span { display: inline-grid; justify-items: center; gap: 1px; white-space: nowrap; }
.member-awakening { color: #8d96a5; font-size: 10px; font-weight: 750; line-height: 1.2; }
.member-name { line-height: 1.35; }
.member-tag { margin-top: 3px; padding: 1px 6px; border: 1px solid #394150; border-radius: 5px; color: #aeb8c8; background: #202630; font-size: 10px; font-weight: 600; line-height: 1.4; }
.rank-members .added-member .member-awakening { color: #c987a4; }
.rank-members > i { flex: 0 0 auto; color: #596270; font-style: normal; }
.rank-members .added-member { color: #f3a5c6; }
@@ -1482,6 +1572,9 @@
.awakening-guide { color: #aa8797; }
.awakening-guide + .probability-guide { margin-top: 5px; }
.ranking-detail { padding: 0 16px 18px; border-top: 1px solid #2b313c; }
.analysis-link { display: inline-block; margin-left: auto; padding: 5px 9px; border: 1px solid #694256; border-radius: 6px; background: #32202b; color: #f2a3c4; font-size: 12px; font-weight: 750; text-decoration: none; white-space: nowrap; }
.analysis-link:hover { background: #46303b; }
.analysis-link:focus-visible { outline: 2px solid #f2a3c4; outline-offset: 3px; }
.scores { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
.scores div { display: grid; gap: 7px; padding: 15px; border-radius: 10px; background: #22202a; }
.scores span { color: #a9b1be; font-size: 14px; }
@@ -1489,6 +1582,12 @@
.baseline-scores { margin-top: 10px; }
.empty { display: grid; place-items: center; min-height: 150px; margin-top: 18px; padding: 28px; border: 1px dashed #303744; border-radius: 12px; color: #858f9f; font-size: 14px; text-align: center; }
@media (max-width: 1100px) {
article.ranking-card > .ranking-summary { grid-template-columns: 48px minmax(0, 1fr) 280px; }
article.ranking-card .rank-team { grid-column: 2 / -1; }
article.ranking-card .team-meta { grid-column: 2; grid-row: 2; }
article.ranking-card .rank-metric { grid-column: 3; grid-row: 2; }
article.ranking-card .rank-probabilities { grid-column: 2 / -1; grid-row: 3; }
.analysis-link { grid-column: 2 / -1; grid-row: 4; }
.fixed-column-headings,
.fixed-field { grid-template-columns: minmax(0, 1fr) 120px 304px 180px; }
}
@@ -1505,11 +1604,15 @@
.leader-fields, .scores { grid-template-columns: 1fr; }
.outfit-effect { grid-template-columns: 1fr; gap: 5px; }
.baseline-members { grid-template-columns: 1fr; }
.ranking-card summary { grid-template-columns: 48px minmax(0, 1fr); gap: 12px 10px; padding: 13px 34px 13px 12px; }
.ranking-summary, article.ranking-card > .ranking-summary { grid-template-columns: 48px minmax(0, 1fr); gap: 10px; padding: 12px; }
.rank-number { grid-column: 1; grid-row: 1; }
.rank-members { grid-column: 2; grid-row: 1; }
.rank-metric:nth-of-type(3) { grid-column: 2; grid-row: 2; }
.rank-probabilities { grid-column: 1 / -1; grid-row: 3; padding: 10px 0 0; border-top: 1px solid #303744; border-left: 0; }
.rank-team, article.ranking-card .rank-team { grid-column: 2; grid-row: 1; }
.rank-metric { grid-column: 2; grid-row: 2; }
article.ranking-card .rank-metric { grid-column: 2; grid-row: 2; }
.rank-probabilities, article.ranking-card .rank-probabilities { grid-column: 1 / -1; grid-row: 3; padding: 10px 0 0; border-top: 1px solid #303744; border-left: 0; }
article.ranking-card .team-meta { grid-column: 1 / -1; grid-row: 3; display: flex; flex-wrap: wrap; gap: 8px; font-size: 11px; }
article.ranking-card .rank-probabilities { grid-row: 4; }
.analysis-link { grid-column: 1 / -1; grid-row: 5; }
.ranking-detail { padding: 0 10px 14px; }
}
</style>