Files
HolodoriCalc/src/routes/combination/+page.svelte
T
dal4segno d26b9ca884
Publish GitHub Pages output / publish (push) Successful in 54s
feat: streamline search results and open captured analyses
2026-09-09 19:15:25 +09:00

1619 lines
92 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import cardSummary from '$lib/data/cards-summary.json';
import { leaderOutfitCards } from '$lib/engine/leader-outfits.ts';
import boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json';
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { events } from '$lib/data/events.ts';
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
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 {
additionalAwakeningsRequired,
calculateBoardEffects,
calculateUnitPower,
combinationSearchLimits,
compareEventRankings,
countOptimizationCandidates,
effectiveActiveProbability,
eventBonusBreakdown,
eventChallengeSongScoreBonusPercent,
eventOptimizationCardEligible,
eventPointIndex,
generateAnchoredCombinationCandidates,
generateCombinationCandidates,
memoryParameterPermil,
perfectNoteScoreFromPower,
resolveCandidateAwakening,
selectMemberDiverseCandidates,
toOptimizerCard,
withinMediumProbabilityCardLimit
} from '$lib/engine/index.ts';
import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard, ScoreAchievementProbabilities } from '$lib/engine/index.ts';
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
export let eventMode = false;
type CardRow = (typeof cardSummary.cards)[number];
import { difficulties, difficultyLabel, isDifficulty, type DifficultyKey } from '$lib/data/difficulty.ts';
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
type MemberBoards = Record<string, string[]>;
type BoardCards = Record<string, Record<string, string>>;
type PreparedCandidate = {
cards: OptimizerCard[];
overallPower: number;
perfectNoteScore: number;
directSongScoreBonusPercent: number;
heuristic: number;
skillPotential: number;
eventBonusPercent?: number;
rankingMultiplier?: number;
optimizationObjective?: 'maximum' | 'expected';
};
type RankedResult = {
result: ExactOptimizationResult;
overallPower: number;
cards?: OptimizerCard[];
directSongScoreBonusPercent?: number;
eventBonusPercent?: number;
rankingMultiplier?: number;
achievementProbabilities?: ScoreAchievementProbabilities;
};
type DisplayRankedResult = RankedResult & { isBaseline: boolean };
type SavedState = {
schemaVersion: 1;
songId: string;
difficulty: DifficultyKey;
fixedCardIds: string[];
awakening: number;
candidateGenerationBonus?: number | null;
fixedAwakenings?: number[];
fixedCooldownReductions?: (CooldownReduction | null)[];
fixedAllBlueNodes?: boolean[];
fixedMaxGenerationBonuses?: boolean[];
fixedGenerationBonusOverrides?: (number | null)[];
songScoreBonusOverride?: number | null;
ownedCardsOnly?: boolean;
maxMediumProbabilityCards?: number | null;
maximizeCompute?: boolean;
resultLimit?: number;
leaderCharacterId: string;
leaderOutfitCardId: string;
};
type SavedAnalysisState = {
schemaVersion: 2;
songId: string;
difficulty: DifficultyKey;
cardIds: string[];
awakenings: number[];
fixedCooldownReductions: (CooldownReduction | null)[];
allBlueNodes: boolean[];
generationBonusOverrides: (number | null)[];
songScoreBonusOverride?: number | null;
leaderCharacterId: string;
leaderOutfitCardId: string;
};
const STORAGE_KEY = eventMode ? 'holodori.event-combination.v1' : 'holodori.combination.v1';
const PRESET_STORAGE_KEY = eventMode ? 'holodori.event-combination.presets.v1' : 'holodori.combination.presets.v1';
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
const allCards = cardSummary.cards;
const fiveStarCards = allCards.filter((card) => card.rarity === 5);
const eventBonusCharacters = new Set<string>(events.flatMap((event) => [...event.bonusCharacterIds]));
const eligibleCards = eventMode
? allCards.filter((card) => eventOptimizationCardEligible(card, eventBonusCharacters))
: fiveStarCards;
const cardById = new Map(allCards.map((card) => [card.id, card]));
const allCharts = exactChartCatalog.charts;
const initialReleaseClock = createInitialReleaseClock($page.data?.serverNow);
let currentReleaseClock = initialReleaseClock;
let releaseNow = currentReleaseClock.now();
let charts = releasedSongCharts(allCharts, releaseNow);
const blueGroupsByCharacter = Object.fromEntries(boardSummary.members.map((member) => [
member.id,
(boardSummary.models as Record<string, { groupId: string }[]>)[member.positionGroupId]
?.filter((position) => position.groupId.startsWith('B'))
.map((position) => position.groupId) ?? []
]));
let songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
const leaderMembers = fiveStarCards.filter((card, index, cards) => cards.findIndex((entry) => entry.characterId === card.characterId) === index);
let collection: CollectionState = {};
let memberBoards: MemberBoards = {};
let boardCards: BoardCards = {};
let memoryCount = 0;
let memberPowerUpPercent = 0;
let selectedSongId = songs[0]?.id ?? '';
let difficulty: DifficultyKey = 'expert';
let fixedCardIds = ['', '', '', ''];
let candidateAwakening = eventMode ? 0 : 5;
let candidateGenerationBonus: number | null = null;
let fixedAwakenings = eventMode ? [0, 0, 0, 0] : [5, 5, 5, 5];
let fixedCooldownReductions: (CooldownReduction | null)[] = [null, null, null, null];
let fixedAllBlueNodes = [false, false, false, false];
let fixedGenerationBonusOverrides: (number | null)[] = [null, null, null, null];
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 ?? '';
let worker: Worker | null = null;
let parallelWorkers: Worker[] = [];
let baselineWorker: Worker | null = null;
let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let result: ExactOptimizationResult | null = null;
let rankedResults: RankedResult[] = [];
let displayedRankings: DisplayRankedResult[] = [];
let baselineState: SavedAnalysisState | null = null;
let baselineResult: ExactOptimizationResult | null = null;
let baselineAchievementProbabilities: ScoreAchievementProbabilities | undefined;
let baselineOverallPower = 0;
let baselineRunning = false;
let baselineProgress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let baselineError = '';
let screenedCombinationCount = 0;
let exactCombinationCount = 0;
let exactPass: 'primary' | 'refinement' = 'primary';
let activeSearchChart: ExactSongChart | null = null;
let primaryRankings: RankedResult[] = [];
let searchPhase: 'idle' | 'screening' | 'exact' | 'probability' = 'idle';
let screeningProgress = 0;
let errorMessage = '';
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);
const stored = JSON.parse(raw ?? 'null');
collection = stored?.collection && typeof stored.collection === 'object' ? stored.collection : {};
memberBoards = stored?.memberBoards && typeof stored.memberBoards === 'object' ? stored.memberBoards : {};
boardCards = stored?.boardCards && typeof stored.boardCards === 'object' ? stored.boardCards : {};
memoryCount = Number.isFinite(stored?.memoryCount) ? stored.memoryCount : 0;
memberPowerUpPercent = Number.isFinite(stored?.memberPowerUpPercent) ? stored.memberPowerUpPercent : 0;
loadedUserData = raw;
userDataChanged = false;
}
function resetBaseline(): void {
baselineTransfer = null;
baselineWorker?.terminate();
const nextWorker = createScoreOptimizerWorker();
baselineWorker = nextWorker;
baselineResult = null;
baselineAchievementProbabilities = undefined;
baselineRunning = false;
baselineError = '';
nextWorker.onmessage = (event) => {
if (baselineWorker !== nextWorker) return;
if (event.data.type === 'progress') baselineProgress = event.data.progress;
if (event.data.type === 'result') {
baselineResult = event.data.result;
baselineAchievementProbabilities = event.data.achievementProbabilities;
baselineRunning = false;
}
if (event.data.type === 'error') {
baselineError = event.data.message;
baselineRunning = false;
}
};
}
$: charts = releasedSongCharts(allCharts, releaseNow);
$: currentEvent = eventMode
? events.find((event) => Date.parse(event.startsAt) <= releaseNow && releaseNow <= Date.parse(event.endsAt)) ?? null
: null;
$: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
$: if (!songs.some((song) => song.id === selectedSongId)) selectedSongId = songs[0]?.id ?? '';
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'formula-v1' ? '곡별 공식' : selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
$: baselineChartMetadata = baselineState
? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty)
: undefined;
$: baselineMatchesConditions = !eventMode && Boolean(baselineState?.songId === selectedSongId && baselineState?.difficulty === difficulty);
$: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions);
$: displayedRankings = buildDisplayedRankings(
rankedResults,
comparableToBaseline ? {
result: baselineResult!,
overallPower: baselineOverallPower,
achievementProbabilities: baselineAchievementProbabilities,
isBaseline: true
} : null,
activeResultLimit
);
$: if (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline();
$: if (!result && rankedResults.length > 0) rankedResults = [];
$: fixedCards = fixedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
$: candidateCards = ownedCardsOnly
? eligibleCards.filter((card) => collection[card.id]?.owned === true)
: eligibleCards;
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
$: leaderOutfitOptions = leaderMember
? leaderOutfitCards(allCards, leaderCharacterId)
: [];
$: leaderOutfit = cardById.get(leaderOutfitCardId)?.details.costume ?? null;
$: leaderOutfitDescription = (leaderOutfit?.description ?? '')
.replace(/\[[^\]]+\]/g, '')
.replace(/\s+/g, ' ')
.trim();
$: connectionCards = cardSummary.cards.filter((card) => card.details.connect.length > 0).map((card) => ({
id: card.id,
awakening: collection[card.id]?.awakening ?? 0,
connect: card.details.connect
}));
$: presetSnapshot = {
schemaVersion: 1,
songId: selectedSongId,
difficulty,
fixedCardIds,
awakening: candidateAwakening,
candidateGenerationBonus,
fixedAwakenings,
fixedCooldownReductions,
fixedAllBlueNodes,
fixedGenerationBonusOverrides,
songScoreBonusOverride,
ownedCardsOnly,
maxMediumProbabilityCards,
resultLimit,
maximizeCompute,
leaderCharacterId,
leaderOutfitCardId
} satisfies SavedState;
$: if (ready) saveState({
schemaVersion: 1,
songId: selectedSongId,
difficulty,
fixedCardIds,
awakening: candidateAwakening,
candidateGenerationBonus,
fixedAwakenings,
fixedCooldownReductions,
fixedAllBlueNodes,
fixedGenerationBonusOverrides,
songScoreBonusOverride,
ownedCardsOnly,
maxMediumProbabilityCards,
resultLimit,
maximizeCompute,
leaderCharacterId,
leaderOutfitCardId
});
onMount(() => {
let mounted = true;
void synchronizeReleaseClock().then((clock) => {
if (!mounted) return;
currentReleaseClock = clock;
releaseNow = currentReleaseClock.now();
});
const releaseClock = window.setInterval(() => {
releaseNow = currentReleaseClock.now();
}, 1_000);
try {
loadUserData();
} catch {
collection = {};
}
try {
const state = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null') as SavedState | null;
if (state) applySavedState(state);
} catch {
localStorage.removeItem(STORAGE_KEY);
}
worker = createScoreOptimizerWorker();
worker.onmessage = (event) => {
if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'combination-result') {
const rankings = Array.isArray(event.data.rankings)
? event.data.rankings as RankedResult[]
: [{ result: event.data.result, overallPower: event.data.overallPower }];
completeExactPass(rankings);
}
if (event.data.type === 'achievement-progress') progress = event.data.progress;
if (event.data.type === 'achievement-result') {
rankedResults = event.data.rankings as RankedResult[];
result = rankedResults[0]?.result ?? null;
running = false;
searchPhase = 'idle';
}
if (event.data.type === 'error') {
errorMessage = event.data.message;
running = false;
searchPhase = 'idle';
}
};
resetBaseline();
try {
baselineState = readAnalysisState(localStorage.getItem(ANALYSIS_STORAGE_KEY));
} catch {
baselineState = null;
baselineError = '배치 분석의 저장 데이터를 읽지 못했습니다.';
}
ready = true;
const syncStorage = (event: StorageEvent) => {
if (event.storageArea !== localStorage || (event.key !== USER_DATA_STORAGE_KEY && event.key !== null)) return;
// Keep the calculation's growth snapshot and all existing results intact.
// Read the current value because queued storage events may already be outdated.
userDataChanged = localStorage.getItem(USER_DATA_STORAGE_KEY) !== loadedUserData;
};
window.addEventListener('storage', syncStorage);
return () => {
mounted = false;
window.removeEventListener('storage', syncStorage);
window.clearInterval(releaseClock);
worker?.terminate();
terminateParallelWorkers();
baselineWorker?.terminate();
};
});
function normalizeAwakening(value: unknown): number {
return Math.max(0, Math.min(5, Number.isFinite(value) ? Math.round(Number(value)) : 5));
}
function normalizeFixedReduction(value: unknown): CooldownReduction | null {
return value === 0 || value === 4 || value === 8 || value === 12 ? value : null;
}
function normalizeGenerationBonus(value: unknown): number {
return Math.max(0, Math.min(900, Number.isFinite(value) ? Math.round(Number(value)) : 0));
}
function normalizeSongScoreBonusOverride(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return null;
return Math.min(10, value);
}
function sanitizeGenerationBonusInput(input: HTMLInputElement, previousValue: number | null): string {
if (input.value === '') {
input.value = '';
return '';
}
if (!/^\d+$/.test(input.value)) {
input.value = previousValue === null ? '' : String(previousValue);
return input.value;
}
const value = Number(input.value);
if (value > 900) {
input.value = previousValue === null ? '' : String(previousValue);
return input.value;
}
input.value = String(value);
return input.value;
}
function saveState(state: SavedState): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function applySavedState(state: Partial<SavedState>): void {
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 (isDifficulty(state.difficulty)) difficulty = state.difficulty;
fixedCardIds = Array.from({ length: 4 }, (_, index) => cardById.has(state.fixedCardIds?.[index] ?? '') ? state.fixedCardIds![index] : '');
candidateAwakening = normalizeAwakening(state.awakening);
candidateGenerationBonus = typeof state.candidateGenerationBonus === 'number' && Number.isFinite(state.candidateGenerationBonus)
? normalizeGenerationBonus(state.candidateGenerationBonus)
: null;
fixedAwakenings = Array.from({ length: 4 }, (_, index) => normalizeAwakening(state.fixedAwakenings?.[index] ?? state.awakening));
fixedCooldownReductions = Array.from({ length: 4 }, (_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index]));
fixedAllBlueNodes = Array.from({ length: 4 }, (_, index) => state.fixedAllBlueNodes?.[index] === true);
fixedGenerationBonusOverrides = Array.from({ length: 4 }, (_, index) => {
const savedValue = state.fixedGenerationBonusOverrides?.[index];
if (typeof savedValue === 'number' && Number.isFinite(savedValue)) return normalizeGenerationBonus(savedValue);
return state.fixedMaxGenerationBonuses?.[index] === true ? 900 : null;
});
songScoreBonusOverride = state.songScoreBonusOverride === null
? 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;
if (ownedCardsOnly) toggleOwnedCardsOnly(true);
maximizeCompute = state.maximizeCompute === true;
if (typeof state.leaderCharacterId === 'string' && leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) {
leaderCharacterId = state.leaderCharacterId;
}
const outfit = typeof state.leaderOutfitCardId === 'string' ? cardById.get(state.leaderOutfitCardId) : undefined;
leaderOutfitCardId = outfit?.characterId === leaderCharacterId && outfit.details.costume
? outfit.id
: fiveStarCards.find((card) => card.characterId === leaderCharacterId && card.details.costume)?.id ?? '';
}
function applyPreset(event: CustomEvent<unknown>): void {
try {
applySavedState(event.detail as Partial<SavedState>);
result = null;
rankedResults = [];
errorMessage = '';
} catch {
errorMessage = '이 프리셋의 탐색 조건을 불러오지 못했습니다.';
}
}
function readAnalysisState(stored: string | null): SavedAnalysisState | null {
if (!stored) return null;
const state = JSON.parse(stored) as Partial<SavedAnalysisState>;
if (state.schemaVersion !== 2 || !Array.isArray(state.cardIds) || state.cardIds.length !== 5) return null;
const cards = state.cardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
if (cards.length !== 5 || new Set(cards.map((card) => card.characterId)).size !== 5) return null;
return {
schemaVersion: 2,
songId: typeof state.songId === 'string' ? state.songId : '',
difficulty: isDifficulty(state.difficulty) ? state.difficulty : 'expert',
cardIds: cards.map((card) => card.id),
awakenings: cards.map((_, index) => Number.isFinite(state.awakenings?.[index]) ? normalizeAwakening(state.awakenings?.[index]) : 0),
fixedCooldownReductions: cards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index])),
allBlueNodes: cards.map((_, index) => state.allBlueNodes?.[index] === true),
generationBonusOverrides: cards.map((_, index) => {
const value = state.generationBonusOverrides?.[index];
return typeof value === 'number' && Number.isFinite(value) ? normalizeGenerationBonus(value) : null;
}),
songScoreBonusOverride: normalizeSongScoreBonusOverride(state.songScoreBonusOverride),
leaderCharacterId: typeof state.leaderCharacterId === 'string' ? state.leaderCharacterId : '',
leaderOutfitCardId: typeof state.leaderOutfitCardId === 'string' ? state.leaderOutfitCardId : ''
};
}
function cardLabel(card: CardRow): string {
return `${collection[card.id]?.owned ? '● ' : ''}${card.rarity}★ ${card.name}${card.versionName ? ` · ${card.versionName}` : ''}`;
}
function updateFixedCard(slot: number, cardId: string): void {
const cardChanged = fixedCardIds[slot] !== cardId;
fixedCardIds = fixedCardIds.map((id, index) => index === slot ? cardId : id);
if (cardChanged) {
fixedCooldownReductions = fixedCooldownReductions.map((reduction, index) => index === slot ? null : reduction);
fixedGenerationBonusOverrides = fixedGenerationBonusOverrides.map((bonus, index) => index === slot ? null : bonus);
}
if (cardId && collection[cardId]?.owned) {
fixedAwakenings = fixedAwakenings.map((value, index) => index === slot ? normalizeAwakening(collection[cardId]?.awakening) : value);
}
result = null;
}
function updateFixedAwakening(slot: number, value: number): void {
fixedAwakenings = fixedAwakenings.map((awakening, index) => index === slot ? normalizeAwakening(value) : awakening);
result = null;
}
function updateCandidateAwakening(value: number): void {
candidateAwakening = normalizeAwakening(value);
result = null;
}
function updateFixedReduction(slot: number, value: CooldownReduction | null): void {
fixedCooldownReductions = fixedCooldownReductions.map((reduction, index) => index === slot ? value : reduction);
result = null;
}
function toggleFixedAllBlueNodes(slot: number): void {
fixedAllBlueNodes = fixedAllBlueNodes.map((enabled, index) => index === slot ? !enabled : enabled);
result = null;
}
function updateFixedGenerationBonus(slot: number, rawValue: string): void {
const parsed = Number(rawValue);
const value = rawValue.trim() === '' || !Number.isFinite(parsed) ? null : normalizeGenerationBonus(parsed);
fixedGenerationBonusOverrides = fixedGenerationBonusOverrides.map((bonus, index) => index === slot ? value : bonus);
result = null;
}
function updateCandidateGenerationBonus(rawValue: string): void {
const parsed = Number(rawValue);
candidateGenerationBonus = rawValue.trim() === '' || !Number.isFinite(parsed) ? null : normalizeGenerationBonus(parsed);
result = null;
}
function updateSongScoreBonusOverride(input: HTMLInputElement): void {
const parsed = Number(input.value);
if (input.value.trim() === '' || !Number.isFinite(parsed) || parsed < 0) input.value = '';
if (parsed > 10) input.value = '10';
songScoreBonusOverride = input.value === '' ? null : Math.min(10, parsed);
result = null;
}
function toggleOwnedCardsOnly(enabled: boolean): void {
ownedCardsOnly = enabled;
if (enabled) {
const removedSlots = fixedCardIds.map((cardId) => Boolean(cardId && !collection[cardId]?.owned));
fixedCardIds = fixedCardIds.map((cardId, slot) => removedSlots[slot] ? '' : cardId);
fixedCooldownReductions = fixedCooldownReductions.map((value, slot) => removedSlots[slot] ? null : value);
fixedAllBlueNodes = fixedAllBlueNodes.map((value, slot) => removedSlots[slot] ? false : value);
fixedGenerationBonusOverrides = fixedGenerationBonusOverrides.map((value, slot) => removedSlots[slot] ? null : value);
}
result = null;
}
function fixedSlot(cardId: string): number {
return fixedCardIds.indexOf(cardId);
}
function awakeningForCard(card: CardRow): number {
const slot = fixedSlot(card.id);
if (slot >= 0) return fixedAwakenings[slot];
const storedCard = collection[card.id];
return resolveCandidateAwakening(
candidateAwakening,
Boolean(storedCard?.owned),
normalizeAwakening(storedCard?.awakening)
);
}
function updateLeader(characterId: string): void {
leaderCharacterId = characterId;
leaderOutfitCardId = fiveStarCards.find((card) => card.characterId === characterId && card.details.costume)?.id ?? '';
result = null;
}
function roughCardScore(card: CardRow): number {
const statTotal = Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0);
const { activeValue, specialValue } = roughSkillValues(card);
const score = statTotal * (1 + activeValue / 100 + specialValue / 500);
if (!currentEvent) return score;
const bonus = eventBonusBreakdown(currentEvent, [card], (candidate) => awakeningForCard(candidate as CardRow));
return score * (1 + bonus.totalPercent / 100);
}
function roughStatScore(card: CardRow): number {
return Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0);
}
function roughSkillScore(card: CardRow): number {
const { adapted, activeValue, specialValue } = roughSkillValues(card);
return activeValue + specialValue + (adapted.scoreSupportPercent ?? 0);
}
function roughSkillValues(card: CardRow) {
const adapted = toOptimizerCard(card, awakeningForCard(card), [0], [card]);
const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds /
Math.max(1, adapted.active.cooldownSeconds) * effectiveActiveProbability(adapted);
const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100;
return { adapted, activeValue, specialValue };
}
function roughCombinationScore(cards: readonly CardRow[]): number {
const statTotal = cards.reduce((sum, card) => sum + roughStatScore(card), 0);
const skillPotential = cards.reduce((sum, card) => {
const adapted = toOptimizerCard(card, awakeningForCard(card), [0], cards);
const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds /
Math.max(1, adapted.active.cooldownSeconds) * effectiveActiveProbability(adapted);
const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100;
const activationValue = (adapted.special.activationRateUpPercent ?? 0) * adapted.special.durationSeconds / 100;
const passiveValue = (adapted.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) =>
effectSum + effect.percent * (effect.target === 'all' ? cards.length : effect.targetCount ?? 1), 0);
return sum + activeValue + specialValue + activationValue + passiveValue;
}, 0);
const score = statTotal * (1 + skillPotential / 500);
if (!currentEvent) return score;
const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow));
const songBonus = eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id));
return score * (1 + bonus.totalPercent / 100) * (1 + songBonus / 100);
}
function roughEventBonusPriority(cards: readonly CardRow[]): number {
if (!currentEvent) return roughCombinationScore(cards);
const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow));
// 최대 보너스 편성이 순수 스코어 후보에 밀려 선별 단계에서 사라지지 않도록 별도 층을 보존한다.
return bonus.totalPercent * 1_000_000_000_000 + roughCombinationScore(cards);
}
function rankingValue(ranking: RankedResult): number {
return eventMode
? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0)
: ranking.result.theoreticalMaxScore;
}
function compareRankedResults(left: RankedResult, right: RankedResult): number {
if (eventMode) return compareEventRankings(left, right);
return rankingValue(right) - rankingValue(left) ||
right.result.expectedScore - left.result.expectedScore ||
right.result.theoreticalMaxScore - left.result.theoreticalMaxScore;
}
function combinationKey(cards: readonly CardRow[] | readonly OptimizerCard[]): string {
return cards.map((card) => card.id).sort().join('|');
}
function boardsForCombination(cards: readonly CardRow[]): MemberBoards {
const boards = Object.fromEntries(Object.entries(memberBoards).map(([characterId, groupIds]) => [characterId, Array.from(groupIds)]));
for (const card of cards) {
const slot = fixedSlot(card.id);
if (slot >= 0 && !fixedAllBlueNodes[slot]) continue;
const existingNonBlue = (boards[card.characterId] ?? []).filter((groupId) => !groupId.startsWith('B'));
boards[card.characterId] = [...existingNonBlue, ...(blueGroupsByCharacter[card.characterId] ?? [])];
}
return boards;
}
function acceptsCombination(cards: readonly CardRow[]): boolean {
return withinMediumProbabilityCardLimit(cards, maxMediumProbabilityCards);
}
function screeningCombinations(): CardRow[][] {
const limits = combinationSearchLimits(fixedCards.length);
const screeningLimit = eventMode ? Math.min(limits.screeningLimit, 9_000) : limits.screeningLimit;
if (fixedCards.length === 4) {
return generateCombinationCandidates(fixedCards, candidateCards, 5, roughCardScore, screeningLimit, undefined, acceptsCombination);
}
const globalBudget = eventMode ? screeningLimit : Math.ceil(screeningLimit * 0.8);
const anchoredBudget = screeningLimit - globalBudget;
const strategies = eventMode
? [
{ scoreCard: roughCardScore },
{ scoreCard: roughStatScore, scoreCombination: roughCombinationScore },
{ scoreCard: roughCardScore, scoreCombination: roughEventBonusPriority }
]
: [
{ scoreCard: roughCardScore },
{ scoreCard: roughSkillScore },
{ scoreCard: roughStatScore },
{ scoreCard: roughStatScore, scoreCombination: roughCombinationScore }
];
const perStrategy = Math.ceil(globalBudget / strategies.length);
const combined = new Map<string, CardRow[]>();
for (const strategy of strategies) {
for (const cards of generateCombinationCandidates(
fixedCards,
candidateCards,
5,
strategy.scoreCard,
perStrategy,
strategy.scoreCombination,
acceptsCombination
)) {
combined.set(combinationKey(cards), cards);
}
}
if (eventMode) return Array.from(combined.values()).slice(0, screeningLimit);
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
const anchorCount = candidateCards.filter((card) => !fixedCharacters.has(card.characterId)).length;
const perAnchorLimit = Math.max(1, Math.ceil(anchoredBudget / Math.max(1, anchorCount)));
for (const cards of generateAnchoredCombinationCandidates(
fixedCards,
candidateCards,
5,
strategies,
perAnchorLimit,
acceptsCombination
)) {
combined.set(combinationKey(cards), cards);
}
return Array.from(combined.values()).slice(0, screeningLimit);
}
function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[] {
if (fixedCards.length === 4) return prepared;
const { baseExactFinalistLimit } = combinationSearchLimits(fixedCards.length);
const finalistLimit = eventMode ? Math.min(baseExactFinalistLimit, 96) : baseExactFinalistLimit;
const selected = new Map<string, PreparedCandidate>();
const add = (candidates: PreparedCandidate[], count: number) => {
for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate);
};
const diverse = selectMemberDiverseCandidates(
prepared,
(candidate) => candidate.cards,
(candidate) => candidate.heuristic,
fixedCardIds,
Math.ceil(finalistLimit / 2)
);
add(diverse, diverse.length);
const remainingLimit = Math.max(0, finalistLimit - selected.size);
const heuristicLimit = Math.ceil(remainingLimit / 2);
const skillLimit = Math.ceil(remainingLimit / 4);
const powerLimit = remainingLimit - heuristicLimit - skillLimit;
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), heuristicLimit);
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), skillLimit);
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), powerLimit);
if (selected.size < finalistLimit) {
for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) {
selected.set(combinationKey(candidate.cards), candidate);
if (selected.size >= finalistLimit) break;
}
}
return Array.from(selected.values()).slice(0, finalistLimit);
}
function buildRefinementFinalists(rankings: RankedResult[]): PreparedCandidate[] {
if (fixedCards.length === 4 || rankings.length === 0) return [];
const { alternativeExactFinalistLimit } = combinationSearchLimits(fixedCards.length);
const alternatives = new Map<string, { cards: CardRow[]; roughScore: number }>();
const topResultNeighbors = new Map<string, { cards: CardRow[]; roughScore: number }>();
const secondaryPriority = new Map<string, { cards: CardRow[]; roughScore: number }>();
const fixedIds = new Set(fixedCards.map((card) => card.id));
for (const [rankingIndex, ranking] of rankings.slice(0, 10).entries()) {
const sourceCards = ranking.result.order.map((cardId) => cardById.get(cardId)).filter((card): card is CardRow => Boolean(card));
if (sourceCards.length !== 5) continue;
const sourceKey = combinationKey(sourceCards);
const bestByReplacementId = new Map<string, { cards: CardRow[]; roughScore: number }>();
sourceCards.forEach((source, index) => {
if (fixedIds.has(source.id)) return;
const otherCharacters = new Set(sourceCards.filter((_, cardIndex) => cardIndex !== index).map((card) => card.characterId));
for (const replacement of candidateCards) {
if (replacement.id === source.id || otherCharacters.has(replacement.characterId)) continue;
const cards = sourceCards.map((card, cardIndex) => cardIndex === index ? replacement : card);
if (!acceptsCombination(cards)) continue;
const key = combinationKey(cards);
if (key === sourceKey) continue;
const candidate = alternatives.get(key) ?? { cards, roughScore: roughCombinationScore(cards) };
alternatives.set(key, candidate);
if (rankingIndex === 0) topResultNeighbors.set(key, candidate);
const previous = bestByReplacementId.get(replacement.id);
if (!previous || candidate.roughScore > previous.roughScore) bestByReplacementId.set(replacement.id, candidate);
}
});
if (rankingIndex > 0 && rankingIndex < 3) {
for (const candidate of bestByReplacementId.values()) secondaryPriority.set(combinationKey(candidate.cards), candidate);
}
}
const refinementLimit = eventMode
? Math.min(alternativeExactFinalistLimit, 48)
: Math.max(alternativeExactFinalistLimit, topResultNeighbors.size);
const rankedAlternatives = Array.from(alternatives.values()).sort((left, right) =>
right.roughScore - left.roughScore || combinationKey(left.cards).localeCompare(combinationKey(right.cards)));
const chosenAlternatives = new Map<string, { cards: CardRow[]; roughScore: number }>();
const rankedTopNeighbors = [...topResultNeighbors.values()].sort((left, right) => right.roughScore - left.roughScore);
for (const candidate of rankedTopNeighbors) {
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
if (chosenAlternatives.size >= refinementLimit) break;
}
for (const candidate of secondaryPriority.values()) {
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
if (chosenAlternatives.size >= refinementLimit) break;
}
for (const candidate of rankedAlternatives) {
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
if (chosenAlternatives.size >= refinementLimit) break;
}
return Array.from(chosenAlternatives.values()).map((candidate) => prepareCandidate(candidate.cards));
}
function prepareCandidate(cards: CardRow[]): PreparedCandidate {
const generationBonusByCardId = Object.fromEntries(cards
.filter((card) => {
const slot = fixedSlot(card.id);
return slot < 0 ? candidateGenerationBonus !== null : fixedGenerationBonusOverrides[slot] !== null;
})
.map((card) => {
const slot = fixedSlot(card.id);
return [card.id, slot < 0 ? candidateGenerationBonus ?? 0 : fixedGenerationBonusOverrides[slot] ?? 0];
}));
const boardEffects = calculateBoardEffects(boardSummary, boardsForCombination(cards), cards, leaderCharacterId, selectedChart, {
placements: boardCards,
cards: connectionCards
}, {
generationBonusByCardId,
...(songScoreBonusOverride === null ? {} : { directSongScoreBonusPercent: songScoreBonusOverride })
});
const power = calculateUnitPower(
cards.map((card) => ({ card, awakening: awakeningForCard(card) })),
leaderOutfit,
boardEffects,
memoryParameterPermil(memoryCount, memorySummary.effects),
memberPowerUpPercent
);
const optimizerCards = cards.map((card) => {
const slot = fixedSlot(card.id);
const fixedReduction = slot >= 0 ? fixedCooldownReductions[slot] : null;
const adapted = toOptimizerCard(
card,
awakeningForCard(card),
fixedReduction === null ? boardEffects.allowedCooldownReductionsByCardId[card.id] : [fixedReduction],
cards
);
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
return {
...adapted,
active: { ...adapted.active, activationRateUpPercent: activationRate },
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
};
});
const skillPotential = optimizerCards.reduce((sum, card) => sum +
card.active.scoreUpPercent * card.active.durationSeconds / Math.max(1, card.active.cooldownSeconds) * effectiveActiveProbability(card) +
card.special.scoreUpPercent * card.special.durationSeconds / 100 +
(card.scoreSupportPercent ?? 0) +
(card.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) => effectSum + effect.percent * (effect.target === 'all' ? 5 : effect.targetCount ?? 1), 0), 0);
const eventBonus = currentEvent
? eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow))
: null;
const eventSongScoreBonus = currentEvent
? eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id))
: 0;
const directSongScoreBonusPercent = boardEffects.directSongScoreBonusPercent + eventSongScoreBonus;
const rankingMultiplier = eventBonus ? 1 + eventBonus.totalPercent / 100 : undefined;
return {
cards: optimizerCards,
overallPower: power.overallPower,
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, selectedChart?.scoreRatio ?? null),
directSongScoreBonusPercent,
heuristic: power.overallPower * (1 + skillPotential / 500) * (rankingMultiplier ?? 1) * (1 + eventSongScoreBonus / 100),
skillPotential,
optimizationObjective: eventMode ? 'expected' : 'maximum',
...(eventBonus ? {
eventBonusPercent: eventBonus.totalPercent,
rankingMultiplier
} : {})
};
}
async function analyzeBaseline(): Promise<void> {
const targetWorker = baselineWorker;
const state = baselineState;
const chartMetadata = baselineChartMetadata;
if (!state || !chartMetadata || !baselineWorker || !baselineMatchesConditions) return;
baselineRunning = true;
baselineError = '';
baselineProgress = { evaluatedCandidates: 0, totalCandidates: 1 };
try {
const cards = state.cardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
if (cards.length !== 5) throw new Error('기준 조합의 카드 정보를 찾지 못했습니다.');
const outfit = cardById.get(state.leaderOutfitCardId)?.details.costume ?? null;
const baselineBoards = Object.fromEntries(Object.entries(memberBoards).map(([characterId, groupIds]) => [characterId, [...groupIds]]));
cards.forEach((card, index) => {
if (!state.allBlueNodes[index]) return;
const nonBlueGroups = (baselineBoards[card.characterId] ?? []).filter((groupId) => !groupId.startsWith('B'));
baselineBoards[card.characterId] = [...nonBlueGroups, ...(blueGroupsByCharacter[card.characterId] ?? [])];
});
const generationBonusByCardId = Object.fromEntries(cards
.map((card, index) => [card.id, state.generationBonusOverrides[index]] as const)
.filter((entry): entry is readonly [string, number] => entry[1] !== null));
const boardEffects = calculateBoardEffects(boardSummary, baselineBoards, cards, state.leaderCharacterId, chartMetadata, {
placements: boardCards,
cards: connectionCards
}, {
generationBonusByCardId,
...(state.songScoreBonusOverride === null || state.songScoreBonusOverride === undefined
? {}
: { directSongScoreBonusPercent: state.songScoreBonusOverride })
});
const power = calculateUnitPower(
cards.map((card, index) => ({ card, awakening: state.awakenings[index] })),
outfit,
boardEffects,
memoryParameterPermil(memoryCount, memorySummary.effects),
memberPowerUpPercent
);
const optimizerCards = cards.map((card, index) => {
const fixedReduction = state.fixedCooldownReductions[index];
const adapted = toOptimizerCard(
card,
state.awakenings[index],
fixedReduction === null ? boardEffects.allowedCooldownReductionsByCardId[card.id] : [fixedReduction],
cards
);
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
return {
...adapted,
active: { ...adapted.active, activationRateUpPercent: activationRate },
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
};
});
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({
type: 'optimize-exact',
cards: optimizerCards,
chart,
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, chartMetadata.scoreRatio ?? null),
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
includeAchievementProbabilities: true
});
} catch (error) {
if (baselineWorker !== targetWorker) return;
baselineError = error instanceof Error ? error.message : '기준 조합을 분석하지 못했습니다.';
baselineRunning = false;
}
}
function scoreDelta(current: number, baseline: number): string {
const difference = current - baseline;
return `${difference >= 0 ? '+' : ''}${difference.toLocaleString('ko-KR')}`;
}
function probabilityLabel(value: number | undefined): string {
if (value === undefined) return '—';
if (value <= 0) return '0%';
if (value < 0.000001) return '<0.0001%';
if (value < 0.0001) return `${(value * 100).toFixed(4)}%`;
if (value < 0.01) return `${(value * 100).toFixed(2)}%`;
return `${(value * 100).toFixed(1)}%`;
}
function scoreThreshold(maximumScore: number, ratio: number): string {
return Math.ceil(maximumScore * ratio).toLocaleString('ko-KR');
}
function terminateParallelWorkers(): void {
for (const parallelWorker of parallelWorkers) parallelWorker.terminate();
parallelWorkers = [];
}
function finalizeRankings(rankings: RankedResult[]): void {
const bestByCombination = new Map<string, RankedResult>();
for (const ranking of rankings) {
const key = [...ranking.result.order].sort().join('|');
const previous = bestByCombination.get(key);
if (!previous || compareRankedResults(ranking, previous) < 0) {
bestByCombination.set(key, ranking);
}
}
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';
progress = { evaluatedCandidates: 0, totalCandidates: rankedResults.length };
worker.postMessage({
type: 'estimate-achievement',
rankings: rankedResults,
chart: activeSearchChart,
sampleCount: 25_000
});
return;
}
running = false;
searchPhase = 'idle';
}
function completeExactPass(rankings: RankedResult[]): void {
if (exactPass === 'primary' && activeSearchChart) {
primaryRankings = rankings;
const refinements = buildRefinementFinalists(rankings);
if (refinements.length > 0) {
exactPass = 'refinement';
exactCombinationCount += refinements.length;
if (maximizeCompute) runParallelExact(refinements, activeSearchChart);
else worker?.postMessage({ type: 'optimize-combinations', candidates: refinements, chart: activeSearchChart, resultLimit: activeResultLimit });
return;
}
}
finalizeRankings([...primaryRankings, ...rankings]);
}
function runParallelExact(finalists: PreparedCandidate[], chart: ExactSongChart): void {
terminateParallelWorkers();
const reportedCores = typeof navigator === 'undefined' ? 1 : navigator.hardwareConcurrency || 4;
const workerCount = Math.min(finalists.length, Math.max(1, reportedCores));
const groups = Array.from({ length: workerCount }, () => [] as PreparedCandidate[]);
finalists.forEach((candidate, index) => groups[index % workerCount].push(candidate));
const groupProgress = Array.from({ length: workerCount }, () => 0);
const totalCandidates = finalists.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0);
const completedRankings: RankedResult[] = [];
let completedWorkers = 0;
let failed = false;
progress = { evaluatedCandidates: 0, totalCandidates };
const fail = (message: string) => {
if (failed) return;
failed = true;
terminateParallelWorkers();
errorMessage = message;
running = false;
searchPhase = 'idle';
};
groups.forEach((candidates, workerIndex) => {
const parallelWorker = createScoreOptimizerWorker();
parallelWorkers.push(parallelWorker);
parallelWorker.onmessage = (event) => {
if (failed) return;
if (event.data.type === 'progress') {
groupProgress[workerIndex] = event.data.progress.evaluatedCandidates;
progress = {
evaluatedCandidates: groupProgress.reduce((sum, value) => sum + value, 0),
totalCandidates
};
}
if (event.data.type === 'combination-result') {
const rankings = Array.isArray(event.data.rankings)
? event.data.rankings as RankedResult[]
: [{ result: event.data.result, overallPower: event.data.overallPower }];
completedRankings.push(...rankings);
completedWorkers += 1;
parallelWorker.terminate();
if (completedWorkers !== workerCount) return;
completedRankings.sort(compareRankedResults);
parallelWorkers = [];
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, resultLimit: activeResultLimit });
});
}
async function search(): Promise<void> {
if (running) return;
errorMessage = '';
try {
const previousUserData = loadedUserData;
loadUserData();
if (previousUserData !== loadedUserData) resetBaseline();
// Rebuild reactive connection cards and candidate ownership before searching.
await tick();
if (running) return;
} catch {
errorMessage = '사용자 데이터를 읽지 못했습니다. 사용자 데이터 페이지에서 저장 상태를 확인해 주세요.';
return;
}
result = null;
if (!worker || !selectedChart) return;
if (ownedCardsOnly && new Set(candidateCards.map((card) => card.characterId)).size < 5) {
errorMessage = '서로 다른 멤버의 보유 5성 카드가 5장 이상 필요합니다.';
return;
}
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 고정할 수 없습니다.';
return;
}
if (!acceptsCombination(fixedCards)) {
errorMessage = `고정 카드의 액티브 발동 확률 ‘중간’ 카드가 최대 ${maxMediumProbabilityCards}장 제한을 초과합니다. 고정 카드나 제한을 변경해 주세요.`;
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;
primaryRankings = [];
screeningProgress = 0;
progress = { evaluatedCandidates: 0, totalCandidates: 1 };
await new Promise((resolve) => setTimeout(resolve, 0));
try {
const combinations = screeningCombinations();
screenedCombinationCount = combinations.length;
const prepared: PreparedCandidate[] = [];
for (let index = 0; index < combinations.length; index += 1) {
prepared.push(prepareCandidate(combinations[index]));
if (index % 50 === 49 || index === combinations.length - 1) {
screeningProgress = (index + 1) / combinations.length;
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
const finalists = selectExactFinalists(prepared);
exactCombinationCount = finalists.length;
if (finalists.length === 0) throw new Error('조건에 맞는 카드 조합이 없습니다.');
const chart = await loadExactSongChart(selectedChart.path);
activeSearchChart = chart;
searchPhase = 'exact';
if (maximizeCompute) runParallelExact(finalists, chart);
else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart, resultLimit: activeResultLimit });
} catch (error) {
errorMessage = error instanceof Error ? error.message : '조합 탐색을 준비하지 못했습니다.';
running = false;
searchPhase = 'idle';
}
}
function baselineCardLabel(cardId: string): string {
const card = cardById.get(cardId);
const index = baselineState?.cardIds.indexOf(cardId) ?? -1;
const awakening = index >= 0 ? baselineState?.awakenings[index] ?? 0 : 0;
return card ? `${card.name}${card.versionName ? ` · ${card.versionName}` : ''} · ${awakening}개화` : cardId;
}
function resultCardName(cardId: string): string {
const card = cardById.get(cardId);
return card ? `${card.name}${card.versionName ? ` · ${card.versionName}` : ''}` : cardId;
}
function resultCardAwakening(cardId: string): number {
const card = cardById.get(cardId);
return card ? awakeningForCard(card) : 0;
}
function displayedCardAwakening(cardId: string, isBaseline: boolean): number {
if (!isBaseline) return resultCardAwakening(cardId);
const index = baselineState?.cardIds.indexOf(cardId) ?? -1;
return index >= 0 ? baselineState?.awakenings[index] ?? 0 : 0;
}
function displayedCardAdditionalAwakenings(cardId: string, isBaseline: boolean): number {
return additionalAwakeningsRequired(collection[cardId], displayedCardAwakening(cardId, isBaseline));
}
function displayedAdditionalAwakenings(ranking: DisplayRankedResult): number {
return ranking.result.formationOrder.reduce(
(total, cardId) => total + displayedCardAdditionalAwakenings(cardId, ranking.isBaseline),
0
);
}
function displayedCompositionKey(ranking: DisplayRankedResult): string {
return ranking.result.order
.map((cardId) => `${cardId}:${displayedCardAwakening(cardId, ranking.isBaseline)}`)
.sort()
.join('|');
}
function buildDisplayedRankings(
rankings: readonly RankedResult[],
baseline: DisplayRankedResult | null,
limit = 10
): DisplayRankedResult[] {
if (rankings.length === 0) return [];
const unique = new Map<string, DisplayRankedResult>();
for (const ranking of rankings) {
const display = { ...ranking, isBaseline: false };
unique.set(displayedCompositionKey(display), display);
}
if (baseline) {
// 카드와 개화가 같다면 실제 배치 분석값인 기준 조합을 대표 결과로 사용한다.
unique.set(displayedCompositionKey(baseline), baseline);
}
return Array.from(unique.values())
.sort((left, right) => compareRankedResults(left, right) || Number(right.isBaseline) - Number(left.isBaseline))
.slice(0, limit);
}
</script>
<svelte:head><title>{eventMode ? '이벤트 Pt 최적화' : '조합 탐색'} · 홀로도리 계산기</title></svelte:head>
<main>
<header>
<h1>{eventMode ? '이벤트 Pt 최적화' : '조합 탐색'}</h1>
<p class="page-description">{eventMode ? '평균 점수에 편성·신규 카드·개화 보너스를 반영한 평균 이벤트 Pt 지수가 높은 순서로 조합을 추천합니다. 스페셜 순서도 평균 점수를 기준으로 최적화합니다.' : '악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.'}</p>
{#if eventMode && currentEvent}
<div class="event-summary" role="status">
<strong>{currentEvent.name}</strong>
<span>대상 멤버 1명당 +{currentEvent.formationBonusPercent}% · 신규 카드 추가 +{currentEvent.featuredCardBonusPercent}% · 과제곡 대응 카드 스코어 +{currentEvent.challengeSongScoreBonusPercent}%</span>
</div>
{/if}
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
{/if}
{#if !eventMode}<p class="baseline-guide"><strong>기준 조합</strong>은 배치 분석 결과가 저장되어 있고, 저장된 악곡과 난이도가 현재 탐색 조건과 같을 때 표시됩니다.</p>{/if}
</header>
{#if baselineState && baselineMatchesConditions}
<section class="panel baseline-panel" aria-labelledby="baseline-title">
<div class="section-title">
<div><span>00</span><h2 id="baseline-title">기준 조합</h2></div>
<strong>배치 분석 저장값</strong>
</div>
<p class="baseline-condition">{baselineChartMetadata?.title} · {difficultyLabel(baselineState.difficulty)}</p>
<div class="baseline-members">
{#each baselineResult?.formationOrder ?? baselineState.cardIds as cardId, slot}
<div><span>{slot + 1}</span><strong>{baselineCardLabel(cardId)}</strong></div>
{/each}
</div>
{#if baselineResult}
<div class="scores baseline-scores">
<div><span>종합력</span><strong>{baselineOverallPower.toLocaleString('ko-KR')}</strong></div>
<div><span>평균 점수</span><strong>{baselineResult.expectedScore.toLocaleString('ko-KR')}</strong></div>
<div><span>최고 점수</span><strong>{baselineResult.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
</div>
{:else if baselineRunning}
<p class="baseline-status">배치 분석의 저장 조건으로 기준 점수를 계산하고 있습니다.</p>
<progress value={baselineProgress.evaluatedCandidates} max={Math.max(1, baselineProgress.totalCandidates)}></progress>
{:else if baselineError}
<p class="error" role="alert">{baselineError}</p>
{/if}
</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>
<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>
<div class="candidate-fields">
<div class="candidate-awakening">
<span>자동 후보 개화</span>
<div class="awakening-stepper">
<button type="button" disabled={candidateAwakening <= 0} aria-label="자동 후보 개화 낮추기" on:click={() => updateCandidateAwakening(candidateAwakening - 1)}>‹</button>
<strong>{candidateAwakening}</strong>
<button type="button" disabled={candidateAwakening >= 5} aria-label="자동 후보 개화 높이기" on:click={() => updateCandidateAwakening(candidateAwakening + 1)}>›</button>
</div>
</div>
<label>자동 후보 기수 보너스 덮어쓰기 (0~900)<input class="number-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="3" value={candidateGenerationBonus ?? ''} placeholder="미사용" on:input={(event) => updateCandidateGenerationBonus(sanitizeGenerationBonusInput(event.currentTarget, candidateGenerationBonus))}></label>
<label>가창 보너스 덮어쓰기 (0~10%)<input class="number-input" type="number" min="0" max="10" step="any" value={songScoreBonusOverride ?? ''} placeholder="실제 보드값" on:input={(event) => updateSongScoreBonusOverride(event.currentTarget)}></label>
</div>
<div class="probability-limit-field">
<label>액티브 발동 확률 ‘중간’ 카드 최대 장수
<select bind:value={maxMediumProbabilityCards} disabled={running} aria-describedby="probability-limit-guide" on:change={() => { result = null; errorMessage = ''; }}>
<option value={null}>제한 없음</option>
{#each [0, 1, 2, 3, 4] as count}<option value={count}>{count}장{count === 0 ? ' (사용 안 함)' : ' 이하'}</option>{/each}
</select>
</label>
<p id="probability-limit-guide" class="candidate-guide">고정 카드를 포함한 5장 전체에 적용합니다. 보드의 발동률 증가와 관계없이 카드의 기본 확률을 기준으로 셉니다.</p>
</div>
<label class="owned-cards-option">
<input type="checkbox" checked={ownedCardsOnly} disabled={running} on:change={(event) => toggleOwnedCardsOnly(event.currentTarget.checked)}>
<span aria-hidden="true"></span>
<strong>내가 보유한 카드만 사용하기</strong>
</label>
<p class="candidate-guide">
‘내가 보유한 카드만 사용하기’를 켜면 고정 카드와 자동 후보 모두 {eventMode ? '보유한 이벤트 후보 카드' : '보유 5성 카드'}로 제한됩니다.<br>
보유 카드의 개화가 자동 후보 개화보다 높으면 실제 보유 개화를 우선 적용합니다.<br>
기수 보너스는 녹색 노드를 활성화해 얻을 수 있는 보너스를 의미합니다.
</p>
<div class="fixed-fields">
<div class="fixed-column-headings" aria-hidden="true">
<span>고정 카드</span><span>개화</span><span>빈도 감소 고정</span>
<div><span>파란 노드 전체</span><span class="generation-heading">기수 보너스<br>덮어쓰기 (0~900)</span></div>
</div>
{#each fixedCardIds as selectedId, slot}
<div class="fixed-field">
<label><span>{slot + 1}번 고정</span>
<select value={selectedId} on:change={(event) => updateFixedCard(slot, event.currentTarget.value)}>
<option value="">고정하지 않음</option>
{#each candidateCards as card}<option value={card.id} disabled={fixedCardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId)}>{cardLabel(card)}</option>{/each}
</select>
</label>
<div class="fixed-awakening">
<div class="awakening-stepper">
<button type="button" disabled={!selectedId || fixedAwakenings[slot] <= 0} aria-label={`${slot + 1}번 고정 카드 개화 낮추기`} on:click={() => updateFixedAwakening(slot, fixedAwakenings[slot] - 1)}>‹</button>
<strong>{fixedAwakenings[slot]}</strong>
<button type="button" disabled={!selectedId || fixedAwakenings[slot] >= 5} aria-label={`${slot + 1}번 고정 카드 개화 높이기`} on:click={() => updateFixedAwakening(slot, fixedAwakenings[slot] + 1)}>›</button>
</div>
</div>
<div class="fixed-reduction">
<div class="reduction-options">
{#each REDUCTION_LOCK_OPTIONS as reduction}
<button type="button" disabled={!selectedId} class:active={fixedCooldownReductions[slot] === reduction} aria-pressed={fixedCooldownReductions[slot] === reduction} on:click={() => updateFixedReduction(slot, reduction)}>{reduction === null ? '미사용' : `${reduction}%`}</button>
{/each}
</div>
</div>
<div class="fixed-overrides">
<label class="override-checkbox">
<input type="checkbox" disabled={!selectedId} checked={fixedAllBlueNodes[slot]} aria-label={`${slot + 1}번 고정 카드 파란 노드 전체 활성화`} on:change={() => toggleFixedAllBlueNodes(slot)}>
<span aria-hidden="true"></span>
</label>
<input class="generation-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="3" disabled={!selectedId} value={fixedGenerationBonusOverrides[slot] ?? ''} placeholder="미사용" aria-label={`${slot + 1}번 고정 카드 기수 보너스 덮어쓰기 (0~900)`} on:input={(event) => updateFixedGenerationBonus(slot, sanitizeGenerationBonusInput(event.currentTarget, fixedGenerationBonusOverrides[slot] ?? null))}>
</div>
</div>
{/each}
</div>
<p class="fixed-guide">‘파란 노드 전체’를 사용하지 않으면 사용자 데이터에 저장된 해당 멤버의 보드를 사용합니다.</p>
<div class="leader-fields">
<label>리더<select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}>{#each leaderMembers as member}<option value={member.characterId}>{member.name}</option>{/each}</select></label>
<label>리더 의상<select bind:value={leaderOutfitCardId} on:change={() => result = null}>{#each leaderOutfitOptions as card}<option value={card.id}>{card.details.costume?.name}</option>{/each}</select></label>
</div>
<div class="outfit-effect">
<span>리더 의상 효과</span>
<p>{leaderOutfitDescription || '적용할 수 있는 의상이 없습니다.'}</p>
</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">
<input type="checkbox" bind:checked={maximizeCompute} disabled={running}>
<span aria-hidden="true"></span>
<strong>최대 컴퓨팅 자원 활용</strong>
</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}
<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}
<span class:added-member={!ranking.isBaseline && !fixedCardIds.includes(cardId)}>
<small class="member-awakening">
{displayedCardAwakening(cardId, ranking.isBaseline)}개화
{#if displayedCardAdditionalAwakenings(cardId, ranking.isBaseline) > 0}
· +{displayedCardAdditionalAwakenings(cardId, ranking.isBaseline)}회
{/if}
</small>
<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}
{#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>
<span><b>100%</b><strong>{probabilityLabel(ranking.achievementProbabilities?.maximum)}</strong></span>
<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>
{#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}
durationSeconds={selectedChart?.durationSeconds ?? 0}
cardName={resultCardName}
instanceId={`combination-${rank}`}
baseline={comparableToBaseline && !ranking.isBaseline ? baselineResult : null}
showScoreSummary={false}
/>
</div>
{/if}
</svelte:element>
{/each}
</div>
<p class="awakening-guide">추가 개화는 현재 카드 데이터와 조합의 목표 개화를 비교한 값입니다. 미보유 카드를 처음 얻는 과정도 1회로 계산합니다.</p>
{#if eventMode}<p class="event-point-guide">순위는 최고점이 아닌 평균 이벤트 Pt 지수 기준입니다. 평균 점수(액티브 발동 확률을 반영한 기대값)에 편성·신규 카드·개화 획득 배율을 적용해 비교하며, 지수가 같으면 평균 점수가 높은 조합을 우선합니다. 게임 서버의 최종 반올림과 멀티 라이브 보정에 따라 실제 획득 Pt는 달라질 수 있습니다.</p>{/if}
<p class="probability-guide">달성 확률은 모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
{:else}
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
{/if}
</section>
</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; }
.page-description { margin: 10px 0 0; color: #929cab; font-size: var(--page-description-size); line-height: 1.6; }
.calibration-warning { width: fit-content; margin: 10px 0 0; padding: 8px 11px; border: 1px solid #604653; border-radius: 8px; color: #c8aeb9; background: #261d23; font-size: 13px; line-height: 1.55; }
.calibration-warning strong { margin-right: 5px; color: #f1a3c4; }
.baseline-guide { width: fit-content; margin: 10px 0 0; padding: 8px 11px; border-radius: 8px; color: #8d96a5; background: #1b2029; font-size: 13px; line-height: 1.55; }
.baseline-guide strong { color: #dba0b9; }
.event-summary { display: grid; gap: 5px; width: fit-content; margin-top: 12px; padding: 11px 13px; border: 1px solid #604653; border-radius: 10px; background: #261d23; }
.event-summary strong { color: #f2a6c6; font-size: 14px; }
.event-summary span { color: #c9b4bd; font-size: 12px; line-height: 1.5; }
.panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; }
.section-title, .section-title > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.section-title > div { justify-content: flex-start; }
.section-title span { color: #ef8cb6; font-size: 13px; font-weight: 900; }
.section-title h2 { margin: 0; font-size: 18px; }
.section-title > strong { color: #aeb6c4; font-size: 14px; }
.baseline-panel { border-color: #4a3a4b; background: #1c1920; }
.baseline-condition { margin: 12px 0 0; color: #b8a8b4; font-size: 14px; }
.baseline-members { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 7px; margin-top: 14px; }
.baseline-members div { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: center; gap: 7px; min-height: 42px; padding: 7px 9px; border: 1px solid #37303a; border-radius: 9px; background: #141218; }
.baseline-members span { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; color: #181118; background: #d987ac; font-size: 12px; font-weight: 900; }
.baseline-members strong { overflow: hidden; color: #e8e1e7; font-size: 12px; line-height: 1.35; text-overflow: ellipsis; }
.baseline-status { margin: 15px 0 0; color: #9ba4b1; font-size: 14px; }
label { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
select, .number-input { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
select:focus, .number-input:focus, .generation-input:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
.song-fields { display: grid; grid-template-columns: minmax(0, 1fr) clamp(110px, 11vw, 170px); align-items: start; gap: 12px; margin-top: 22px; }
.song-picker-field { min-width: 0; }
.chart-meta { display: flex; grid-column: 1 / -1; flex-wrap: wrap; align-items: center; gap: 8px; min-height: 42px; }
.chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 13px; white-space: nowrap; }
.candidate-fields { display: grid; grid-template-columns: clamp(130px, 12vw, 180px) repeat(2, clamp(230px, 21vw, 340px)); gap: 9px; margin-top: 18px; }
.candidate-awakening { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
.candidate-guide { margin: 7px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
.probability-limit-field { margin-top: 16px; }
.probability-limit-field > label { max-width: 340px; }
.fixed-fields { display: grid; gap: 9px; margin-top: 16px; }
.fixed-guide { margin: 8px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
.fixed-column-headings { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(130px, 12vw, 180px) clamp(304px, 27vw, 400px) clamp(180px, 16vw, 240px); gap: 9px; padding: 0 2px; }
.fixed-column-headings > span { align-self: center; padding-left: 2px; color: #8f98a8; font-size: 13px; font-weight: 800; }
.fixed-column-headings > div { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; color: #8f98a8; font-size: 12px; font-weight: 800; text-align: center; }
.generation-heading { line-height: 1.25; white-space: nowrap; }
.fixed-field { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(130px, 12vw, 180px) clamp(304px, 27vw, 400px) clamp(180px, 16vw, 240px); gap: 9px; }
.fixed-field > label { grid-template-columns: 74px minmax(0, 1fr); align-items: center; }
.fixed-awakening, .fixed-reduction { min-width: 0; }
.awakening-stepper { display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 24px; line-height: 1; cursor: pointer; }
.awakening-stepper button:hover:not(:disabled) { color: #f5afcc; background: #2d2530; }
.awakening-stepper button:focus-visible { position: relative; z-index: 1; outline: 2px solid #df74a2; outline-offset: -2px; }
.awakening-stepper strong { color: #e8ebf1; font-size: 14px; text-align: center; white-space: nowrap; }
.reduction-options { display: grid; grid-template-columns: 1.35fr repeat(4, 1fr); height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.reduction-options button { min-width: 0; padding: 0 6px; border: 0; border-left: 1px solid #2d3440; color: #8f98a8; background: transparent; font-size: 13px; font-weight: 750; cursor: pointer; }
.reduction-options button:first-child { border-left: 0; }
.reduction-options button:hover:not(:disabled) { color: #e9edf3; background: #202630; }
.reduction-options button.active { color: #171019; background: #ed8bb5; }
.reduction-options button:focus-visible { position: relative; z-index: 1; outline: 2px solid #f1a0c2; outline-offset: -2px; }
.fixed-overrides { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; align-self: end; height: 42px; }
.override-checkbox { display: grid; place-items: center; cursor: pointer; }
.override-checkbox input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.override-checkbox span { display: grid; place-items: center; width: 22px; height: 22px; border: 1px solid #4a5361; border-radius: 6px; background: #11151b; transition: border-color .12s, background .12s; }
.override-checkbox input:checked + span { border-color: #ed8bb5; background: #ed8bb5; }
.override-checkbox input:checked + span::after { content: '✓'; color: #171019; font-size: 14px; font-weight: 950; }
.override-checkbox input:focus-visible + span { outline: 3px solid rgb(216 111 156 / 20%); }
.override-checkbox input:disabled + span { opacity: .35; cursor: default; }
.generation-input { align-self: center; width: 100%; min-width: 0; height: 32px; padding: 0 7px; border: 1px solid #414957; border-radius: 7px; color: #e8ebf1; background: #11151b; font-size: 12px; font-weight: 800; text-align: center; }
.generation-input::placeholder { color: #68717f; opacity: 1; }
.generation-input:disabled { opacity: .35; }
.awakening-stepper button:disabled, .reduction-options button:disabled { opacity: .35; cursor: default; }
.leader-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 16px; }
.outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 12px; margin-top: 9px; padding: 11px 13px; border: 1px solid #2d3440; border-radius: 9px; background: #12161c; }
.outfit-effect span { color: #d993b1; font-size: 13px; font-weight: 850; }
.outfit-effect p { margin: 0; color: #b9c1cd; font-size: 14px; line-height: 1.55; }
.hint { margin: 13px 0 0; color: #858f9f; font-size: 13px; line-height: 1.6; }
.error { margin: 14px 0 0; color: #ff9f9f; font-size: 13px; }
.search { width: 100%; height: 46px; margin-top: 20px; border: 0; border-radius: 10px; color: #171019; background: #ed8bb5; font-weight: 900; cursor: pointer; }
.search:hover:not(:disabled) { background: #f2a0c2; }
.search:disabled { opacity: .55; cursor: default; }
.owned-cards-option, .compute-option { display: flex; align-items: center; gap: 9px; width: fit-content; margin-top: 12px; cursor: pointer; }
.owned-cards-option input, .compute-option input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.owned-cards-option span, .compute-option span { display: grid; place-items: center; width: 20px; height: 20px; border: 1px solid #4a5361; border-radius: 6px; background: #11151b; }
.owned-cards-option input:checked + span, .compute-option input:checked + span { border-color: #ed8bb5; background: #ed8bb5; }
.owned-cards-option input:checked + span::after, .compute-option input:checked + span::after { content: '✓'; color: #171019; font-size: 13px; font-weight: 950; }
.owned-cards-option input:focus-visible + span, .compute-option input:focus-visible + span { outline: 3px solid rgb(216 111 156 / 20%); }
.owned-cards-option input:disabled + span, .owned-cards-option input:disabled ~ strong,
.compute-option input:disabled + span, .compute-option input:disabled ~ strong { opacity: .5; cursor: default; }
.owned-cards-option strong, .compute-option strong { color: #c9d0da; font-size: 14px; }
.compute-warning { margin: 6px 0 0 29px; color: #a98686; font-size: 13px; line-height: 1.5; }
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.baseline-ranking { border-color: #665061; background: #1b171d; }
.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; }
.baseline-result-label { padding: 3px 7px; border: 1px solid #6a5060; border-radius: 999px; color: #e7b2ca; background: #2a2028; font-size: 10px; font-weight: 850; }
.rank-metric { display: grid; gap: 4px; }
.rank-metric small { color: #9ca5b3; font-size: 13px; font-style: normal; font-weight: 750; }
.rank-metric strong { color: #f0a0c2; font-size: 18px; line-height: 1.2; }
.rank-metric i { color: #90d8ae; font-size: 12px; font-style: normal; font-weight: 800; }
.rank-metric b { width: fit-content; margin-top: 2px; padding: 3px 7px; border-radius: 999px; color: #f0b0c9; background: #30212a; font-size: 11px; font-weight: 850; }
.rank-metric b.complete { color: #9edbb7; background: #1e3028; }
.rank-probabilities { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px 12px; padding-left: 18px; border-left: 1px solid #303744; }
.rank-probabilities > small { grid-column: 1 / -1; color: #9ca5b3; font-size: 12px; font-weight: 750; }
.rank-probabilities > span { display: grid; gap: 2px; }
.rank-probabilities b { color: #7f8998; font-size: 10px; font-weight: 750; }
.rank-probabilities strong { color: #dce1e8; font-size: 14px; line-height: 1.2; }
.rank-probabilities i { color: #7f8998; font-size: 10px; font-style: normal; font-weight: 700; }
.awakening-guide, .probability-guide { margin: 11px 2px 0; color: #7f8998; font-size: 12px; line-height: 1.55; }
.event-point-guide { margin: 5px 2px 0; color: #d1a5b8; font-size: 12px; line-height: 1.55; }
.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; }
.scores strong { color: #f2a3c4; font-size: 19px; }
.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; }
}
@media (max-width: 700px) {
main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
.panel { padding: 17px; }
.song-fields { grid-template-columns: 1fr 100px; }
.chart-meta { grid-column: 1 / -1; height: auto; margin-top: 0; }
.candidate-fields { grid-template-columns: 1fr; }
.fixed-column-headings { display: none; }
.fixed-field { grid-template-columns: minmax(0, 1fr) 102px; }
.fixed-reduction, .fixed-overrides { grid-column: 1 / -1; }
.leader-fields, .scores { grid-template-columns: 1fr; }
.outfit-effect { grid-template-columns: 1fr; gap: 5px; }
.baseline-members { grid-template-columns: 1fr; }
.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-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>