Files
HolodoriCalc/src/routes/combination/+page.svelte
T
2026-08-19 18:56:00 +09:00

1198 lines
66 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 } from 'svelte';
import cardSummary from '$lib/data/cards-summary.json';
import boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json';
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
import AnalysisResultView from '$lib/components/AnalysisResultView.svelte';
import {
calculateBoardEffects,
calculateUnitPower,
combinationSearchLimits,
countOptimizationCandidates,
generateAnchoredCombinationCandidates,
generateCombinationCandidates,
memoryParameterPermil,
perfectNoteScoreFromPower,
resolveCandidateAwakening,
selectMemberDiverseCandidates,
toOptimizerCard
} 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';
type CardRow = (typeof cardSummary.cards)[number];
type DifficultyKey = 'hard' | 'expert';
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;
};
type RankedResult = {
result: ExactOptimizationResult;
overallPower: number;
cards?: OptimizerCard[];
directSongScoreBonusPercent?: 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)[];
maximizeCompute?: boolean;
leaderCharacterId: string;
leaderOutfitCardId: string;
};
type SavedAnalysisState = {
schemaVersion: 2;
songId: string;
difficulty: DifficultyKey;
cardIds: string[];
awakenings: number[];
fixedCooldownReductions: (CooldownReduction | null)[];
leaderCharacterId: string;
leaderOutfitCardId: string;
};
const STORAGE_KEY = 'holodori.combination.v1';
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
const charts = exactChartCatalog.charts;
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) ?? []
]));
const 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 = 5;
let candidateGenerationBonus: number | null = null;
let fixedAwakenings = [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 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;
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
$: baselineChartMetadata = baselineState
? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty)
: undefined;
$: baselineMatchesConditions = Boolean(baselineState?.songId === selectedSongId && baselineState?.difficulty === difficulty);
$: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions);
$: displayedRankings = buildDisplayedRankings(
rankedResults,
comparableToBaseline ? {
result: baselineResult!,
overallPower: baselineOverallPower,
achievementProbabilities: baselineAchievementProbabilities,
isBaseline: true
} : null
);
$: 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));
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
$: leaderOutfitOptions = leaderMember
? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume)
.filter((card, index, cards) => cards.findIndex((entry) => entry.details.costume?.name === card.details.costume?.name) === index)
: [];
$: 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
}));
$: if (ready) saveState({
schemaVersion: 1,
songId: selectedSongId,
difficulty,
fixedCardIds,
awakening: candidateAwakening,
candidateGenerationBonus,
fixedAwakenings,
fixedCooldownReductions,
fixedAllBlueNodes,
fixedGenerationBonusOverrides,
maximizeCompute,
leaderCharacterId,
leaderOutfitCardId
});
onMount(() => {
try {
const stored = JSON.parse(localStorage.getItem(USER_DATA_STORAGE_KEY) ?? '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;
} catch {
collection = {};
}
try {
const state = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null') as SavedState | null;
if (state?.schemaVersion === 1) {
if (songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
if (state.difficulty === 'hard' || state.difficulty === 'expert') 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;
});
maximizeCompute = state.maximizeCompute === true;
if (leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) leaderCharacterId = state.leaderCharacterId;
const outfit = cardById.get(state.leaderOutfitCardId);
leaderOutfitCardId = outfit?.characterId === leaderCharacterId && outfit.details.costume
? outfit.id
: fiveStarCards.find((card) => card.characterId === leaderCharacterId && card.details.costume)?.id ?? '';
}
} 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';
}
};
baselineWorker = createScoreOptimizerWorker();
baselineWorker.onmessage = (event) => {
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;
}
};
try {
baselineState = readAnalysisState(localStorage.getItem(ANALYSIS_STORAGE_KEY));
} catch {
baselineState = null;
baselineError = '배치 분석의 저장 데이터를 읽지 못했습니다.';
}
ready = true;
return () => {
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 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 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: state.difficulty === 'hard' ? 'hard' : '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])),
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.name}${card.versionName ? ` · ${card.versionName}` : ''}`;
}
function selectedInAnotherSlot(card: CardRow, slot: number): boolean {
return fixedCardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId);
}
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 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);
return statTotal * (1 + activeValue / 100 + specialValue / 500);
}
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) * ((adapted.active.probabilityPercent ?? 100) / 100);
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) * ((adapted.active.probabilityPercent ?? 100) / 100);
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);
return statTotal * (1 + skillPotential / 500);
}
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 screeningCombinations(): CardRow[][] {
const { screeningLimit } = combinationSearchLimits(fixedCards.length);
if (fixedCards.length === 4) {
return generateCombinationCandidates(fixedCards, fiveStarCards, 5, roughCardScore, screeningLimit);
}
const globalBudget = Math.ceil(screeningLimit * 0.8);
const anchoredBudget = screeningLimit - globalBudget;
const perStrategy = Math.ceil(globalBudget / 4);
const strategies = [
{ scoreCard: roughCardScore },
{ scoreCard: roughSkillScore },
{ scoreCard: roughStatScore },
{ scoreCard: roughStatScore, scoreCombination: roughCombinationScore }
];
const combined = new Map<string, CardRow[]>();
for (const strategy of strategies) {
for (const cards of generateCombinationCandidates(
fixedCards,
fiveStarCards,
5,
strategy.scoreCard,
perStrategy,
strategy.scoreCombination
)) {
combined.set(combinationKey(cards), cards);
}
}
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
const anchorCount = fiveStarCards.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,
fiveStarCards,
5,
strategies,
perAnchorLimit
)) {
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 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(baseExactFinalistLimit / 2)
);
add(diverse, diverse.length);
const remainingLimit = Math.max(0, baseExactFinalistLimit - 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 < baseExactFinalistLimit) {
for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) {
selected.set(combinationKey(candidate.cards), candidate);
if (selected.size >= baseExactFinalistLimit) break;
}
}
return Array.from(selected.values()).slice(0, baseExactFinalistLimit);
}
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 fiveStarCards) {
if (replacement.id === source.id || otherCharacters.has(replacement.characterId)) continue;
const cards = sourceCards.map((card, cardIndex) => cardIndex === index ? replacement : card);
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 = 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 }>();
for (const candidate of topResultNeighbors.values()) {
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
}
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,
directSongScoreBonusPercent: 10
});
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, probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100) },
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
};
});
const skillPotential = optimizerCards.reduce((sum, card) => sum +
card.active.scoreUpPercent * card.active.durationSeconds / Math.max(1, card.active.cooldownSeconds) * ((card.active.probabilityPercent ?? 100) / 100) +
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);
return {
cards: optimizerCards,
overallPower: power.overallPower,
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, selectedChart?.scoreRatio ?? null),
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
heuristic: power.overallPower * (1 + skillPotential / 500),
skillPotential
};
}
async function analyzeBaseline(): Promise<void> {
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 boardEffects = calculateBoardEffects(boardSummary, memberBoards, cards, state.leaderCharacterId, chartMetadata, {
placements: boardCards,
cards: connectionCards
});
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, probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100) },
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
};
});
const chart = await loadExactSongChart(chartMetadata.path);
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) {
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 || ranking.result.theoreticalMaxScore > previous.result.theoreticalMaxScore ||
(ranking.result.theoreticalMaxScore === previous.result.theoreticalMaxScore && ranking.result.expectedScore > previous.result.expectedScore)) {
bestByCombination.set(key, ranking);
}
}
rankedResults = Array.from(bestByCombination.values()).sort((left, right) =>
right.result.theoreticalMaxScore - left.result.theoreticalMaxScore || right.result.expectedScore - left.result.expectedScore).slice(0, 10);
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 });
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((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
right.result.expectedScore - left.result.expectedScore);
parallelWorkers = [];
completeExactPass(completedRankings.slice(0, 10));
}
if (event.data.type === 'error') fail(event.data.message);
};
parallelWorker.onerror = () => fail('병렬 분석 Worker에서 오류가 발생했습니다.');
parallelWorker.postMessage({ type: 'optimize-combinations', candidates, chart });
});
}
async function search(): Promise<void> {
errorMessage = '';
result = null;
if (!worker || !selectedChart) return;
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 고정할 수 없습니다.';
return;
}
running = true;
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 });
} 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 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
): 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) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
right.result.expectedScore - left.result.expectedScore || Number(right.isBaseline) - Number(left.isBaseline))
.slice(0, 10);
}
</script>
<svelte:head><title>조합 탐색 · 홀로도리 계산기</title></svelte:head>
<main>
<header>
<h1>조합 탐색</h1>
<p class="page-description">악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.</p>
<p class="baseline-guide"><strong>기준 조합</strong>은 배치 분석 결과가 저장되어 있고, 저장된 악곡과 난이도가 현재 탐색 조건과 같을 때 표시됩니다.</p>
</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} · {baselineState.difficulty === 'expert' ? 'Expert' : 'Hard'}</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">
<div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>5성 전체 대상</strong></div>
<div class="song-fields">
<label>악곡<select bind:value={selectedSongId} on:change={() => result = null}>{#each songs as song}<option value={song.id}>{song.title}</option>{/each}</select></label>
<label>난이도<select bind:value={difficulty} on:change={() => result = null}><option value="expert">Expert</option><option value="hard">Hard</option></select></label>
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</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>
</div>
<p class="candidate-guide">
보유 카드의 개화가 자동 후보 개화보다 높으면 실제 보유 개화를 우선 적용합니다.<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 fiveStarCards as card}<option value={card.id} disabled={selectedInAnotherSlot(card, slot)}>{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 || '적용할 수 있는 5성 의상이 없습니다.'}</p>
</div>
<p class="hint">기수 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·악곡 보너스 10% 조건으로 비교합니다.</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}
</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 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>
<span class="rank-number">{rank + 1}위</span>
<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)}개화</small>
<span class="member-name">{resultCardName(cardId)}</span>
</span>
{#if memberIndex < ranking.result.formationOrder.length - 1}<i aria-hidden="true">·</i>{/if}
{/each}
</span>
<span class="rank-metric"><small>최고 점수</small><strong>{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/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>
</summary>
<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>
</details>
{/each}
</div>
<p class="probability-guide">달성 확률은 모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
{:else}
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
{/if}
</section>
</main>
<style>
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; }
.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; }
.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, 2fr) clamp(110px, 11vw, 170px) minmax(220px, .8fr); align-items: end; gap: 12px; margin-top: 22px; }
.chart-meta { display: flex; align-items: center; gap: 8px; 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) clamp(260px, 24vw, 380px); 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; }
.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; }
.compute-option { display: flex; align-items: center; gap: 9px; width: fit-content; margin-top: 12px; cursor: pointer; }
.compute-option input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.compute-option span { display: grid; place-items: center; width: 20px; height: 20px; border: 1px solid #4a5361; border-radius: 6px; background: #11151b; }
.compute-option input:checked + span { border-color: #ed8bb5; background: #ed8bb5; }
.compute-option input:checked + span::after { content: '✓'; color: #171019; font-size: 13px; font-weight: 950; }
.compute-option input:focus-visible + span { outline: 3px solid rgb(216 111 156 / 20%); }
.compute-option input:disabled + span, .compute-option input:disabled ~ strong { opacity: .5; cursor: default; }
.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[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); }
.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; }
.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-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; }
.probability-guide { margin: 11px 2px 0; color: #7f8998; font-size: 12px; line-height: 1.55; }
.ranking-detail { padding: 0 16px 18px; border-top: 1px solid #2b313c; }
.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) {
.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; }
.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-card summary { grid-template-columns: 48px minmax(0, 1fr); gap: 12px 10px; padding: 13px 34px 13px 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; }
.ranking-detail { padding: 0 10px 14px; }
}
</style>