|
|
|
@@ -1,11 +1,15 @@
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
import { onMount } from 'svelte';
|
|
|
|
|
import { page } from '$app/stores';
|
|
|
|
|
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 { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
|
|
|
|
|
import { releasedSongCharts } from '$lib/data/song-release.ts';
|
|
|
|
|
import AnalysisResultView from '$lib/components/AnalysisResultView.svelte';
|
|
|
|
|
import PresetManager from '$lib/components/PresetManager.svelte';
|
|
|
|
|
import {
|
|
|
|
|
calculateBoardEffects,
|
|
|
|
|
calculateUnitPower,
|
|
|
|
@@ -56,6 +60,7 @@
|
|
|
|
|
fixedAllBlueNodes?: boolean[];
|
|
|
|
|
fixedMaxGenerationBonuses?: boolean[];
|
|
|
|
|
fixedGenerationBonusOverrides?: (number | null)[];
|
|
|
|
|
ownedCardsOnly?: boolean;
|
|
|
|
|
maximizeCompute?: boolean;
|
|
|
|
|
leaderCharacterId: string;
|
|
|
|
|
leaderOutfitCardId: string;
|
|
|
|
@@ -72,18 +77,23 @@
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const STORAGE_KEY = 'holodori.combination.v1';
|
|
|
|
|
const PRESET_STORAGE_KEY = 'holodori.combination.presets.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 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) ?? []
|
|
|
|
|
]));
|
|
|
|
|
const songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
|
|
|
|
|
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 = {};
|
|
|
|
@@ -100,6 +110,7 @@
|
|
|
|
|
let fixedCooldownReductions: (CooldownReduction | null)[] = [null, null, null, null];
|
|
|
|
|
let fixedAllBlueNodes = [false, false, false, false];
|
|
|
|
|
let fixedGenerationBonusOverrides: (number | null)[] = [null, null, null, null];
|
|
|
|
|
let ownedCardsOnly = false;
|
|
|
|
|
let maximizeCompute = false;
|
|
|
|
|
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
|
|
|
|
|
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
|
|
|
|
@@ -128,6 +139,9 @@
|
|
|
|
|
let errorMessage = '';
|
|
|
|
|
let ready = false;
|
|
|
|
|
|
|
|
|
|
$: charts = releasedSongCharts(allCharts, releaseNow);
|
|
|
|
|
$: 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);
|
|
|
|
|
$: baselineChartMetadata = baselineState
|
|
|
|
|
? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty)
|
|
|
|
@@ -146,6 +160,9 @@
|
|
|
|
|
$: 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
|
|
|
|
|
? fiveStarCards.filter((card) => collection[card.id]?.owned === true)
|
|
|
|
|
: fiveStarCards;
|
|
|
|
|
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
|
|
|
|
|
$: leaderOutfitOptions = leaderMember
|
|
|
|
|
? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume)
|
|
|
|
@@ -161,6 +178,22 @@
|
|
|
|
|
awakening: collection[card.id]?.awakening ?? 0,
|
|
|
|
|
connect: card.details.connect
|
|
|
|
|
}));
|
|
|
|
|
$: presetSnapshot = {
|
|
|
|
|
schemaVersion: 1,
|
|
|
|
|
songId: selectedSongId,
|
|
|
|
|
difficulty,
|
|
|
|
|
fixedCardIds,
|
|
|
|
|
awakening: candidateAwakening,
|
|
|
|
|
candidateGenerationBonus,
|
|
|
|
|
fixedAwakenings,
|
|
|
|
|
fixedCooldownReductions,
|
|
|
|
|
fixedAllBlueNodes,
|
|
|
|
|
fixedGenerationBonusOverrides,
|
|
|
|
|
ownedCardsOnly,
|
|
|
|
|
maximizeCompute,
|
|
|
|
|
leaderCharacterId,
|
|
|
|
|
leaderOutfitCardId
|
|
|
|
|
} satisfies SavedState;
|
|
|
|
|
$: if (ready) saveState({
|
|
|
|
|
schemaVersion: 1,
|
|
|
|
|
songId: selectedSongId,
|
|
|
|
@@ -172,12 +205,22 @@
|
|
|
|
|
fixedCooldownReductions,
|
|
|
|
|
fixedAllBlueNodes,
|
|
|
|
|
fixedGenerationBonusOverrides,
|
|
|
|
|
ownedCardsOnly,
|
|
|
|
|
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 {
|
|
|
|
|
const stored = JSON.parse(localStorage.getItem(USER_DATA_STORAGE_KEY) ?? 'null');
|
|
|
|
|
collection = stored?.collection && typeof stored.collection === 'object' ? stored.collection : {};
|
|
|
|
@@ -190,29 +233,7 @@
|
|
|
|
|
}
|
|
|
|
|
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 ?? '';
|
|
|
|
|
}
|
|
|
|
|
if (state) applySavedState(state);
|
|
|
|
|
} catch {
|
|
|
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
|
|
|
}
|
|
|
|
@@ -259,6 +280,8 @@
|
|
|
|
|
}
|
|
|
|
|
ready = true;
|
|
|
|
|
return () => {
|
|
|
|
|
mounted = false;
|
|
|
|
|
window.clearInterval(releaseClock);
|
|
|
|
|
worker?.terminate();
|
|
|
|
|
terminateParallelWorkers();
|
|
|
|
|
baselineWorker?.terminate();
|
|
|
|
@@ -302,6 +325,46 @@
|
|
|
|
|
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 (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;
|
|
|
|
|
});
|
|
|
|
|
ownedCardsOnly = state.ownedCardsOnly === true;
|
|
|
|
|
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>;
|
|
|
|
@@ -374,6 +437,18 @@
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
@@ -451,7 +526,7 @@
|
|
|
|
|
function screeningCombinations(): CardRow[][] {
|
|
|
|
|
const { screeningLimit } = combinationSearchLimits(fixedCards.length);
|
|
|
|
|
if (fixedCards.length === 4) {
|
|
|
|
|
return generateCombinationCandidates(fixedCards, fiveStarCards, 5, roughCardScore, screeningLimit);
|
|
|
|
|
return generateCombinationCandidates(fixedCards, candidateCards, 5, roughCardScore, screeningLimit);
|
|
|
|
|
}
|
|
|
|
|
const globalBudget = Math.ceil(screeningLimit * 0.8);
|
|
|
|
|
const anchoredBudget = screeningLimit - globalBudget;
|
|
|
|
@@ -466,7 +541,7 @@
|
|
|
|
|
for (const strategy of strategies) {
|
|
|
|
|
for (const cards of generateCombinationCandidates(
|
|
|
|
|
fixedCards,
|
|
|
|
|
fiveStarCards,
|
|
|
|
|
candidateCards,
|
|
|
|
|
5,
|
|
|
|
|
strategy.scoreCard,
|
|
|
|
|
perStrategy,
|
|
|
|
@@ -476,11 +551,11 @@
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
|
|
|
|
|
const anchorCount = fiveStarCards.filter((card) => !fixedCharacters.has(card.characterId)).length;
|
|
|
|
|
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,
|
|
|
|
|
fiveStarCards,
|
|
|
|
|
candidateCards,
|
|
|
|
|
5,
|
|
|
|
|
strategies,
|
|
|
|
|
perAnchorLimit
|
|
|
|
@@ -537,7 +612,7 @@
|
|
|
|
|
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) {
|
|
|
|
|
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);
|
|
|
|
|
const key = combinationKey(cards);
|
|
|
|
@@ -806,6 +881,10 @@
|
|
|
|
|
errorMessage = '';
|
|
|
|
|
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;
|
|
|
|
@@ -930,7 +1009,7 @@
|
|
|
|
|
</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="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>{ownedCardsOnly ? '보유 5성 대상' : '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>
|
|
|
|
@@ -947,7 +1026,13 @@
|
|
|
|
|
</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>
|
|
|
|
|
<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">
|
|
|
|
|
‘내가 보유한 카드만 사용하기’를 켜면 고정 카드와 자동 후보 모두 보유 5성 카드로 제한됩니다.<br>
|
|
|
|
|
보유 카드의 개화가 자동 후보 개화보다 높으면 실제 보유 개화를 우선 적용합니다.<br>
|
|
|
|
|
기수 보너스는 녹색 노드를 활성화해 얻을 수 있는 보너스를 의미합니다.
|
|
|
|
|
</p>
|
|
|
|
@@ -961,7 +1046,7 @@
|
|
|
|
|
<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}
|
|
|
|
|
{#each candidateCards as card}<option value={card.id} disabled={selectedInAnotherSlot(card, slot)}>{cardLabel(card)}</option>{/each}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<div class="fixed-awakening">
|
|
|
|
@@ -998,6 +1083,7 @@
|
|
|
|
|
<p>{leaderOutfitDescription || '적용할 수 있는 5성 의상이 없습니다.'}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="hint">기수 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·악곡 보너스 10% 조건으로 비교합니다.</p>
|
|
|
|
|
<PresetManager storageKey={PRESET_STORAGE_KEY} snapshot={presetSnapshot} disabled={running} on:apply={applyPreset} />
|
|
|
|
|
{#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">
|
|
|
|
@@ -1125,14 +1211,15 @@
|
|
|
|
|
.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%); }
|
|
|
|
|
.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; }
|
|
|
|
|
.compute-option strong { color: #c9d0da; font-size: 14px; }
|
|
|
|
|
.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; }
|
|
|
|
|