feat: improve analysis and release v1.0.2
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
|
||||
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
|
||||
import { calculateBoardEffects, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
|
||||
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, SkillWindow } from '$lib/engine/index.ts';
|
||||
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } 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';
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
let running = false;
|
||||
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
|
||||
let result: ExactOptimizationResult | null = null;
|
||||
let achievementProbabilities: ScoreAchievementProbabilities | undefined;
|
||||
let errorMessage = '';
|
||||
let analysisReady = false;
|
||||
|
||||
@@ -182,6 +183,7 @@
|
||||
if (event.data.type === 'progress') progress = event.data.progress;
|
||||
if (event.data.type === 'result') {
|
||||
result = event.data.result;
|
||||
achievementProbabilities = event.data.achievementProbabilities;
|
||||
running = false;
|
||||
}
|
||||
if (event.data.type === 'error') {
|
||||
@@ -315,6 +317,7 @@
|
||||
async function analyze(): Promise<void> {
|
||||
errorMessage = '';
|
||||
result = null;
|
||||
achievementProbabilities = undefined;
|
||||
if (!worker || !selectedSong || !selectedChart) return;
|
||||
if (new Set(selectedCardIds).size !== 5) {
|
||||
errorMessage = '서로 다른 카드 5장을 선택해 주세요.';
|
||||
@@ -342,7 +345,8 @@
|
||||
cards,
|
||||
chart,
|
||||
perfectNoteScore,
|
||||
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent
|
||||
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
|
||||
includeAchievementProbabilities: true
|
||||
});
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : '분석 입력을 준비하지 못했습니다.';
|
||||
@@ -354,6 +358,19 @@
|
||||
return card ? cardLabel(card) : cardId;
|
||||
}
|
||||
|
||||
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 countNotesBySecond(scoreResult: ExactOptimizationResult, durationSeconds: number): number[] {
|
||||
const counts = Array.from({ length: Math.ceil(durationSeconds) }, () => 0);
|
||||
for (const note of scoreResult.notes) {
|
||||
@@ -430,7 +447,7 @@
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>배치 분석</h1>
|
||||
<p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 멤버 배치 순서와 빈도 감소 조합을 찾습니다.</p>
|
||||
<p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -528,16 +545,27 @@
|
||||
<section class="panel result" aria-labelledby="result-title">
|
||||
<div class="section-title"><div><span>02</span><h2 id="result-title">분석 결과</h2></div></div>
|
||||
{#if result}
|
||||
<ol class="order-list">
|
||||
{#each result.order as cardId, slot}
|
||||
<li><span class="slot">{slot + 1}</span><strong>{resultCardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
|
||||
{/each}
|
||||
</ol>
|
||||
<div class="order-sections">
|
||||
<section class="order-section">
|
||||
<h3>추천 스페셜 발동 순서</h3>
|
||||
<ol class="order-list">
|
||||
{#each result.order as cardId, slot}
|
||||
<li><span class="slot">{slot + 1}</span><strong>{resultCardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
<div class="score-grid">
|
||||
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong></div>
|
||||
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
|
||||
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div>
|
||||
</div>
|
||||
<div class="probability-grid">
|
||||
<div><span>최고점 100%</span><strong>{probabilityLabel(achievementProbabilities?.maximum)}</strong></div>
|
||||
<div><span>최고점의 99% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast99Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.99)}점 이상</small></div>
|
||||
<div><span>최고점의 95% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast95Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.95)}점 이상</small></div>
|
||||
</div>
|
||||
<p class="probability-guide">모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
|
||||
|
||||
<section class="skill-timeline" aria-labelledby="timeline-title">
|
||||
<div class="timeline-heading">
|
||||
@@ -662,7 +690,10 @@
|
||||
.analyze:disabled { opacity: .55; cursor: default; }
|
||||
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
|
||||
.error { margin: 16px 0 0; color: #ff9f9f; font-size: 13px; }
|
||||
.order-list { display: grid; gap: 8px; padding: 0; margin: 22px 0 0; list-style: none; }
|
||||
.order-sections { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 22px; }
|
||||
.order-section { min-width: 0; }
|
||||
.order-section h3 { margin: 0 0 9px; color: #aeb7c5; font-size: 14px; }
|
||||
.order-list { display: grid; gap: 8px; padding: 0; margin: 0; list-style: none; }
|
||||
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
|
||||
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
|
||||
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -671,6 +702,12 @@
|
||||
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
|
||||
.score-summary span { color: #a9b1be; font-size: 14px; }
|
||||
.score-summary strong { color: #f2a3c4; font-size: 20px; }
|
||||
.probability-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; }
|
||||
.probability-grid > div { display: grid; gap: 7px; padding: 16px; border: 1px solid #303744; border-radius: 10px; background: #171d25; }
|
||||
.probability-grid span { color: #9ca5b3; font-size: 14px; }
|
||||
.probability-grid strong { color: #e5e9ef; font-size: 20px; }
|
||||
.probability-grid small { color: #8d96a5; font-size: 12px; font-weight: 700; }
|
||||
.probability-guide { margin: 9px 2px 0; color: #7f8998; font-size: 12px; line-height: 1.55; }
|
||||
.empty { color: #858f9f; font-size: 14px; line-height: 1.65; }
|
||||
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; }
|
||||
.timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
|
||||
@@ -710,6 +747,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.order-sections { grid-template-columns: 1fr; }
|
||||
main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
|
||||
.page-header { align-items: flex-start; }
|
||||
.panel { padding: 17px; }
|
||||
|
||||
@@ -9,14 +9,17 @@
|
||||
import {
|
||||
calculateBoardEffects,
|
||||
calculateUnitPower,
|
||||
combinationSearchLimits,
|
||||
countOptimizationCandidates,
|
||||
generateAnchoredCombinationCandidates,
|
||||
generateCombinationCandidates,
|
||||
memoryParameterPermil,
|
||||
perfectNoteScoreFromPower,
|
||||
resolveCandidateAwakening,
|
||||
selectMemberDiverseCandidates,
|
||||
toOptimizerCard
|
||||
} from '$lib/engine/index.ts';
|
||||
import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard } 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';
|
||||
|
||||
@@ -33,7 +36,13 @@
|
||||
heuristic: number;
|
||||
skillPotential: number;
|
||||
};
|
||||
type RankedResult = { result: ExactOptimizationResult; overallPower: number };
|
||||
type RankedResult = {
|
||||
result: ExactOptimizationResult;
|
||||
overallPower: number;
|
||||
cards?: OptimizerCard[];
|
||||
directSongScoreBonusPercent?: number;
|
||||
achievementProbabilities?: ScoreAchievementProbabilities;
|
||||
};
|
||||
type DisplayRankedResult = RankedResult & { isBaseline: boolean };
|
||||
type SavedState = {
|
||||
schemaVersion: 1;
|
||||
@@ -64,9 +73,6 @@
|
||||
|
||||
const STORAGE_KEY = 'holodori.combination.v1';
|
||||
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
|
||||
const SCREENING_LIMIT = 4_000;
|
||||
const BASE_EXACT_FINALIST_LIMIT = 48;
|
||||
const ALTERNATIVE_EXACT_FINALIST_LIMIT = 48;
|
||||
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]));
|
||||
@@ -107,13 +113,17 @@
|
||||
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 searchPhase: 'idle' | 'screening' | 'exact' = 'idle';
|
||||
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;
|
||||
@@ -126,7 +136,12 @@
|
||||
$: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions);
|
||||
$: displayedRankings = buildDisplayedRankings(
|
||||
rankedResults,
|
||||
comparableToBaseline ? { result: baselineResult!, overallPower: baselineOverallPower, isBaseline: true } : null
|
||||
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 = [];
|
||||
@@ -205,8 +220,15 @@
|
||||
worker.onmessage = (event) => {
|
||||
if (event.data.type === 'progress') progress = event.data.progress;
|
||||
if (event.data.type === 'combination-result') {
|
||||
result = event.data.result;
|
||||
rankedResults = Array.isArray(event.data.rankings) ? event.data.rankings : [{ result: event.data.result, overallPower: event.data.overallPower }];
|
||||
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';
|
||||
}
|
||||
@@ -221,6 +243,7 @@
|
||||
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') {
|
||||
@@ -405,85 +428,127 @@
|
||||
}
|
||||
|
||||
function screeningCombinations(): CardRow[][] {
|
||||
const { screeningLimit } = combinationSearchLimits(fixedCards.length);
|
||||
if (fixedCards.length === 4) {
|
||||
return generateCombinationCandidates(fixedCards, fiveStarCards, 5, roughCardScore, SCREENING_LIMIT);
|
||||
return generateCombinationCandidates(fixedCards, fiveStarCards, 5, roughCardScore, screeningLimit);
|
||||
}
|
||||
const perStrategy = Math.ceil(SCREENING_LIMIT / 4);
|
||||
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 score of [roughCardScore, roughSkillScore, roughStatScore]) {
|
||||
for (const cards of generateCombinationCandidates(fixedCards, fiveStarCards, 5, score, perStrategy)) {
|
||||
for (const strategy of strategies) {
|
||||
for (const cards of generateCombinationCandidates(
|
||||
fixedCards,
|
||||
fiveStarCards,
|
||||
5,
|
||||
strategy.scoreCard,
|
||||
perStrategy,
|
||||
strategy.scoreCombination
|
||||
)) {
|
||||
combined.set(combinationKey(cards), cards);
|
||||
}
|
||||
}
|
||||
for (const cards of generateCombinationCandidates(
|
||||
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,
|
||||
roughStatScore,
|
||||
perStrategy,
|
||||
roughCombinationScore
|
||||
strategies,
|
||||
perAnchorLimit
|
||||
)) {
|
||||
combined.set(combinationKey(cards), cards);
|
||||
}
|
||||
return Array.from(combined.values()).slice(0, SCREENING_LIMIT);
|
||||
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);
|
||||
};
|
||||
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), 24);
|
||||
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), 12);
|
||||
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), 12);
|
||||
if (selected.size < BASE_EXACT_FINALIST_LIMIT) {
|
||||
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 >= BASE_EXACT_FINALIST_LIMIT) break;
|
||||
if (selected.size >= baseExactFinalistLimit) break;
|
||||
}
|
||||
}
|
||||
return Array.from(selected.values()).slice(0, BASE_EXACT_FINALIST_LIMIT);
|
||||
return Array.from(selected.values()).slice(0, baseExactFinalistLimit);
|
||||
}
|
||||
|
||||
function addAlternativeVariantFinalists(baseFinalists: PreparedCandidate[]): PreparedCandidate[] {
|
||||
if (fixedCards.length === 4) return baseFinalists;
|
||||
const selected = new Map(baseFinalists.map((candidate) => [combinationKey(candidate.cards), candidate]));
|
||||
const alternatives = new Map<string, PreparedCandidate>();
|
||||
const bestByReplacementId = new Map<string, PreparedCandidate>();
|
||||
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 finalist of baseFinalists) {
|
||||
const sourceCards = finalist.cards.map((card) => cardById.get(card.id)).filter((card): card is CardRow => Boolean(card));
|
||||
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.characterId !== source.characterId || replacement.id === source.id) continue;
|
||||
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 (selected.has(key) || alternatives.has(key)) continue;
|
||||
const prepared = prepareCandidate(cards);
|
||||
alternatives.set(key, prepared);
|
||||
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 || prepared.heuristic > previous.heuristic) bestByReplacementId.set(replacement.id, prepared);
|
||||
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.heuristic - left.heuristic || right.skillPotential - left.skillPotential || right.overallPower - left.overallPower);
|
||||
const chosenAlternatives = new Map<string, PreparedCandidate>();
|
||||
for (const candidate of bestByReplacementId.values()) {
|
||||
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 >= ALTERNATIVE_EXACT_FINALIST_LIMIT) break;
|
||||
if (chosenAlternatives.size >= refinementLimit) break;
|
||||
}
|
||||
for (const candidate of chosenAlternatives.values()) {
|
||||
selected.set(combinationKey(candidate.cards), candidate);
|
||||
}
|
||||
return Array.from(selected.values());
|
||||
return Array.from(chosenAlternatives.values()).map((candidate) => prepareCandidate(candidate.cards));
|
||||
}
|
||||
|
||||
function prepareCandidate(cards: CardRow[]): PreparedCandidate {
|
||||
@@ -586,7 +651,8 @@
|
||||
cards: optimizerCards,
|
||||
chart,
|
||||
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, chartMetadata.scoreRatio ?? null),
|
||||
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent
|
||||
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
|
||||
includeAchievementProbabilities: true
|
||||
});
|
||||
} catch (error) {
|
||||
baselineError = error instanceof Error ? error.message : '기준 조합을 분석하지 못했습니다.';
|
||||
@@ -599,11 +665,67 @@
|
||||
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;
|
||||
@@ -649,11 +771,8 @@
|
||||
if (completedWorkers !== workerCount) return;
|
||||
completedRankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
|
||||
right.result.expectedScore - left.result.expectedScore);
|
||||
rankedResults = completedRankings.slice(0, 10);
|
||||
result = rankedResults[0]?.result ?? null;
|
||||
parallelWorkers = [];
|
||||
running = false;
|
||||
searchPhase = 'idle';
|
||||
completeExactPass(completedRankings.slice(0, 10));
|
||||
}
|
||||
if (event.data.type === 'error') fail(event.data.message);
|
||||
};
|
||||
@@ -672,6 +791,9 @@
|
||||
}
|
||||
running = true;
|
||||
searchPhase = 'screening';
|
||||
exactPass = 'primary';
|
||||
activeSearchChart = null;
|
||||
primaryRankings = [];
|
||||
screeningProgress = 0;
|
||||
progress = { evaluatedCandidates: 0, totalCandidates: 1 };
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -686,10 +808,11 @@
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
const finalists = addAlternativeVariantFinalists(selectExactFinalists(prepared));
|
||||
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 });
|
||||
@@ -756,7 +879,7 @@
|
||||
<main>
|
||||
<header>
|
||||
<h1>조합 탐색</h1>
|
||||
<p class="page-description">악곡과 고정 멤버 조건을 바탕으로 빈자리에 들어갈 카드를 탐색해 최고 점수 조합을 찾습니다.</p>
|
||||
<p class="page-description">악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.</p>
|
||||
<p class="baseline-guide"><strong>기준 조합</strong>은 배치 분석 결과가 저장되어 있고, 저장된 악곡과 난이도가 현재 탐색 조건과 같을 때 표시됩니다.</p>
|
||||
</header>
|
||||
{#if baselineState && baselineMatchesConditions}
|
||||
@@ -767,7 +890,7 @@
|
||||
</div>
|
||||
<p class="baseline-condition">{baselineChartMetadata?.title} · {baselineState.difficulty === 'expert' ? 'Expert' : 'Hard'}</p>
|
||||
<div class="baseline-members">
|
||||
{#each baselineResult?.order ?? baselineState.cardIds as cardId, slot}
|
||||
{#each baselineResult?.formationOrder ?? baselineState.cardIds as cardId, slot}
|
||||
<div><span>{slot + 1}</span><strong>{baselineCardLabel(cardId)}</strong></div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -854,7 +977,7 @@
|
||||
</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)}%` : `정밀 분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
|
||||
<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>
|
||||
@@ -874,15 +997,21 @@
|
||||
<span class="rank-number">{rank + 1}위</span>
|
||||
<span class="rank-members">
|
||||
{#if ranking.isBaseline}<b class="baseline-result-label">기준 조합</b>{/if}
|
||||
{#each ranking.result.order as cardId, memberIndex}
|
||||
{#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.order.length - 1}<i aria-hidden="true">·</i>{/if}
|
||||
{#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
|
||||
@@ -897,6 +1026,7 @@
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="probability-guide">달성 확률은 모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
|
||||
{:else}
|
||||
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
|
||||
{/if}
|
||||
@@ -980,7 +1110,7 @@
|
||||
.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(170px, 15vw, 230px); align-items: center; gap: 18px; min-height: 80px; padding: 12px 46px 12px 18px; cursor: pointer; list-style: none; }
|
||||
.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); }
|
||||
@@ -997,6 +1127,13 @@
|
||||
.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; }
|
||||
@@ -1024,6 +1161,7 @@
|
||||
.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>
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
awakenings: number[];
|
||||
leaderCharacterId: string;
|
||||
leaderOutfitCardId: string;
|
||||
coreCubeFreeOnly: boolean;
|
||||
};
|
||||
type GreenNodeCost = { points: number; greenCubes: number; greenCoreCubes: number };
|
||||
type GreenNodeEfficiency = {
|
||||
@@ -54,6 +55,13 @@
|
||||
upgrades: GreenNodeEfficiency[];
|
||||
removals: GreenNodeEfficiency[];
|
||||
};
|
||||
type AutomaticPlacementResult = {
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
cardId: string;
|
||||
cardName: string;
|
||||
rarity: number;
|
||||
};
|
||||
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
|
||||
const EFFICIENCY_FORMATION_STORAGE_KEY = 'holodori.green-efficiency-formation.v1';
|
||||
const GREEN_CUBE_ID = 'item-skill_tree_allmember-1';
|
||||
@@ -100,12 +108,14 @@
|
||||
let selectedSocketGroupId = '';
|
||||
let boardCardSearch = '';
|
||||
let automaticPlacementSnapshot: BoardCardPlacements | null = null;
|
||||
let automaticPlacementResult: AutomaticPlacementResult[] = [];
|
||||
let savedAnalysis: SavedAnalysisState | null = null;
|
||||
let efficiencyFormation: EfficiencyFormationState = {
|
||||
cardIds: ['', '', '', '', ''],
|
||||
awakenings: [0, 0, 0, 0, 0],
|
||||
leaderCharacterId: '',
|
||||
leaderOutfitCardId: ''
|
||||
leaderOutfitCardId: '',
|
||||
coreCubeFreeOnly: false
|
||||
};
|
||||
let greenEfficiencyResult: GreenEfficiencyResult | null = null;
|
||||
let greenEfficiencyRunning = false;
|
||||
@@ -163,7 +173,8 @@
|
||||
cardIds: stored.cardIds.map((id) => cardById.has(id) ? id : ''),
|
||||
awakenings: stored.awakenings.map(normalizeEfficiencyAwakening),
|
||||
leaderCharacterId: leaderMembers.some((member) => member.characterId === stored.leaderCharacterId) ? stored.leaderCharacterId : '',
|
||||
leaderOutfitCardId: cardById.get(stored.leaderOutfitCardId)?.details.costume ? stored.leaderOutfitCardId : ''
|
||||
leaderOutfitCardId: cardById.get(stored.leaderOutfitCardId)?.details.costume ? stored.leaderOutfitCardId : '',
|
||||
coreCubeFreeOnly: stored.coreCubeFreeOnly === true
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -222,13 +233,18 @@
|
||||
saveEfficiencyFormation({ ...efficiencyFormation, leaderOutfitCardId: cardId });
|
||||
}
|
||||
|
||||
function updateCoreCubeFreeOnly(coreCubeFreeOnly: boolean) {
|
||||
saveEfficiencyFormation({ ...efficiencyFormation, coreCubeFreeOnly });
|
||||
}
|
||||
|
||||
function importAnalysisFormation() {
|
||||
if (!savedAnalysis) return;
|
||||
saveEfficiencyFormation({
|
||||
cardIds: [...savedAnalysis.cardIds],
|
||||
awakenings: savedAnalysis.awakenings.map(normalizeEfficiencyAwakening),
|
||||
leaderCharacterId: savedAnalysis.leaderCharacterId,
|
||||
leaderOutfitCardId: savedAnalysis.leaderOutfitCardId
|
||||
leaderOutfitCardId: savedAnalysis.leaderOutfitCardId,
|
||||
coreCubeFreeOnly: efficiencyFormation.coreCubeFreeOnly
|
||||
});
|
||||
}
|
||||
|
||||
@@ -625,6 +641,7 @@
|
||||
const path = shortestPathToSet(position.groupId, active, positions);
|
||||
const added = path.filter((groupId) => groupId !== center && !active.has(groupId));
|
||||
if (added.length === 0) continue;
|
||||
if (efficiencyFormation.coreCubeFreeOnly && greenNodeCost(member.id, added).greenCoreCubes > 0) continue;
|
||||
const nextBoards = { ...userData.memberBoards, [member.id]: [...new Set([...currentNodes, ...added])] };
|
||||
const nextPower = currentFormationPower(nextBoards);
|
||||
const intermediateNodes = added.filter((groupId) => groupId !== position.groupId);
|
||||
@@ -643,6 +660,7 @@
|
||||
const reachable = reachableNodesForPositions(remaining, positions, center);
|
||||
const removed = currentNodes.filter((groupId) => !reachable.has(groupId));
|
||||
if (removed.length === 0) continue;
|
||||
if (efficiencyFormation.coreCubeFreeOnly && greenNodeCost(member.id, removed).greenCoreCubes > 0) continue;
|
||||
const nextMemberNodes = currentNodes.filter((groupId) => reachable.has(groupId));
|
||||
const nextBoards = { ...userData.memberBoards };
|
||||
if (nextMemberNodes.length > 0) nextBoards[member.id] = nextMemberNodes;
|
||||
@@ -788,12 +806,24 @@
|
||||
|
||||
automaticPlacementSnapshot = cloneBoardCards(userData.boardCards);
|
||||
const boardCards = cloneBoardCards(userData.boardCards);
|
||||
const placementResult: AutomaticPlacementResult[] = [];
|
||||
for (const match of matches) {
|
||||
const card = candidateCards[match.rowIndex];
|
||||
const slot = emptyGreenSlots[match.columnIndex];
|
||||
if (!card || !slot) continue;
|
||||
boardCards[slot.member.id] = { ...(boardCards[slot.member.id] ?? {}), [slot.socket.groupId]: card.id };
|
||||
placementResult.push({
|
||||
memberId: slot.member.id,
|
||||
memberName: slot.member.name,
|
||||
cardId: card.id,
|
||||
cardName: cardLabel(card),
|
||||
rarity: card.rarity
|
||||
});
|
||||
}
|
||||
automaticPlacementResult = placementResult.sort((left, right) =>
|
||||
(boardMembers.find((member) => member.id === left.memberId)?.order ?? Number.MAX_SAFE_INTEGER)
|
||||
- (boardMembers.find((member) => member.id === right.memberId)?.order ?? Number.MAX_SAFE_INTEGER)
|
||||
);
|
||||
persist({ ...userData, boardCards });
|
||||
showMessage(`기존 배치를 유지하고 초록색 빈 슬롯 ${matches.length}곳에 보유 카드를 최적 배치했습니다.`);
|
||||
}
|
||||
@@ -802,6 +832,7 @@
|
||||
if (!automaticPlacementSnapshot) return;
|
||||
const boardCards = cloneBoardCards(automaticPlacementSnapshot);
|
||||
automaticPlacementSnapshot = null;
|
||||
automaticPlacementResult = [];
|
||||
persist({ ...userData, boardCards });
|
||||
showMessage('방금 실행한 초록색 카드 자동 배치를 취소했습니다.');
|
||||
}
|
||||
@@ -1111,6 +1142,19 @@
|
||||
{#if automaticPlacementSnapshot}<button type="button" class="undo" on:click={undoAutomaticPlacement}>방금 배치 취소</button>{/if}
|
||||
<button type="button" class="primary" on:click={optimizeEmptyGreenSlots}>중앙 연결 노드 자동 배치</button>
|
||||
</div>
|
||||
{#if automaticPlacementResult.length > 0}
|
||||
<div class="automatic-placement-result" aria-live="polite">
|
||||
<strong>자동 배치 결과 <span>{automaticPlacementResult.length}건</span></strong>
|
||||
<div class="automatic-placement-result-list">
|
||||
{#each automaticPlacementResult as placement (placement.memberId)}
|
||||
<div class="automatic-placement-result-item">
|
||||
<span>{placement.memberName} 보드</span>
|
||||
<b><i>{placement.rarity}★</i>{placement.cardName}</b>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
<details class="green-efficiency">
|
||||
@@ -1173,6 +1217,15 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="efficiency-outfit-effect"><span>리더 의상 효과</span><p>{plainSkillText(cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume?.description ?? '') || '적용하지 않음'}</p></div>
|
||||
<label class="efficiency-core-filter">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={efficiencyFormation.coreCubeFreeOnly}
|
||||
disabled={greenEfficiencyRunning}
|
||||
on:change={(event) => updateCoreCubeFreeOnly(event.currentTarget.checked)}
|
||||
/>
|
||||
<span><strong>코어 큐브 미소모만</strong><small>활성화 또는 초기화 경로 전체에서 그린 코어 큐브를 소모하지 않는 결과만 찾습니다.</small></span>
|
||||
</label>
|
||||
</div>
|
||||
{#if greenEfficiencyError}<p class="efficiency-error" role="alert">{greenEfficiencyError}</p>{/if}
|
||||
{#if greenEfficiencyResult}
|
||||
@@ -1201,7 +1254,7 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}<p class="efficiency-empty">종합력을 올리는 비활성 초록 노드가 없습니다.</p>{/if}
|
||||
{:else}<p class="efficiency-empty">{efficiencyFormation.coreCubeFreeOnly ? '코어 큐브를 소모하지 않고 종합력을 올리는 비활성 초록 노드가 없습니다.' : '종합력을 올리는 비활성 초록 노드가 없습니다.'}</p>{/if}
|
||||
</section>
|
||||
<section class="efficiency-column removal" aria-labelledby="removal-efficiency-title">
|
||||
<div class="efficiency-column-heading">
|
||||
@@ -1221,7 +1274,7 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}<p class="efficiency-empty">초기화를 검토할 활성 초록 노드가 없습니다.</p>{/if}
|
||||
{:else}<p class="efficiency-empty">{efficiencyFormation.coreCubeFreeOnly ? '코어 큐브를 환급하지 않는 활성 초록 노드가 없습니다.' : '초기화를 검토할 활성 초록 노드가 없습니다.'}</p>{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1457,7 +1510,7 @@
|
||||
.board-toolbar p strong { color: #f0a1c2; font-size: 16px; }
|
||||
.board-toolbar div { display: flex; gap: 6px; }
|
||||
.board-toolbar button { padding: 6px 9px; border: 1px solid #394252; border-radius: 7px; color: #aeb7c5; background: #202630; font-size: 12px; font-weight: 750; cursor: pointer; }
|
||||
.automatic-placement { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin: 0 0 18px; padding: 12px 14px; border: 1px solid #355044; border-radius: 10px; background: rgb(58 113 83 / 10%); }
|
||||
.automatic-placement { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 18px; margin: 0 0 18px; padding: 12px 14px; border: 1px solid #355044; border-radius: 10px; background: rgb(58 113 83 / 10%); }
|
||||
.automatic-placement > div:first-child { display: grid; gap: 4px; min-width: 0; }
|
||||
.automatic-placement strong { color: #b7dec7; font-size: 14px; }
|
||||
.automatic-placement small { color: #82988c; font-size: 12px; line-height: 1.45; }
|
||||
@@ -1467,6 +1520,14 @@
|
||||
.automatic-placement button.undo { color: #c4ccd5; background: transparent; }
|
||||
.automatic-placement button:hover { filter: brightness(1.1); }
|
||||
.automatic-placement button:focus-visible { outline: 3px solid rgb(120 196 147 / 25%); outline-offset: 2px; }
|
||||
.automatic-placement-result { display: grid; flex: 1 0 100%; gap: 9px; padding-top: 12px; border-top: 1px solid rgb(120 196 147 / 18%); }
|
||||
.automatic-placement-result > strong { display: flex; align-items: center; gap: 7px; color: #d8e9df; font-size: 14px; }
|
||||
.automatic-placement-result > strong span { color: #78c493; font-size: 12px; }
|
||||
.automatic-placement-result-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 6px 10px; max-height: 280px; overflow-y: auto; padding-right: 4px; }
|
||||
.automatic-placement-result-item { display: grid; grid-template-columns: minmax(105px, 0.8fr) minmax(150px, 1.2fr); align-items: center; gap: 10px; min-height: 38px; padding: 7px 10px; border: 1px solid rgb(120 196 147 / 13%); border-radius: 7px; background: rgb(9 18 13 / 34%); }
|
||||
.automatic-placement-result-item span { color: #91a59a; font-size: 12px; }
|
||||
.automatic-placement-result-item b { display: flex; align-items: center; gap: 7px; min-width: 0; color: #dbe8df; font-size: 13px; }
|
||||
.automatic-placement-result-item i { flex: 0 0 auto; color: #e7bd70; font-size: 11px; font-style: normal; }
|
||||
.green-efficiency { order: 2; margin: 18px 0 0; padding: 0 16px 16px; border: 1px solid #394453; border-radius: 16px; background: #151b23; }
|
||||
.green-efficiency > summary { display: flex; align-items: center; min-height: 62px; margin: 0 -16px; padding: 12px 16px; color: #dce6e0; cursor: pointer; list-style: none; }
|
||||
.green-efficiency > summary::-webkit-details-marker { display: none; }
|
||||
@@ -1503,6 +1564,12 @@
|
||||
.efficiency-outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 10px; margin-top: 9px; padding: 9px 10px; border-radius: 8px; background: #171e26; }
|
||||
.efficiency-outfit-effect span { color: #d990af; font-size: 13px; font-weight: 850; }
|
||||
.efficiency-outfit-effect p { margin: 0; color: #a7b0bd; font-size: 13px; line-height: 1.5; }
|
||||
.efficiency-core-filter { display: flex; align-items: center; gap: 10px; margin-top: 9px; padding: 10px 11px; border: 1px solid #314238; border-radius: 8px; background: #111a15; cursor: pointer; }
|
||||
.efficiency-core-filter input { width: 18px; height: 18px; margin: 0; accent-color: #78c493; cursor: pointer; }
|
||||
.efficiency-core-filter input:disabled { cursor: default; }
|
||||
.efficiency-core-filter > span { display: grid; gap: 2px; }
|
||||
.efficiency-core-filter strong { color: #dbe5df; font-size: 14px; }
|
||||
.efficiency-core-filter small { color: #8f9e96; font-size: 12px; line-height: 1.45; }
|
||||
.efficiency-summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px 12px; margin-top: 14px; padding: 10px 12px; border-radius: 8px; background: #10151c; }
|
||||
.efficiency-summary span { color: #8b96a5; font-size: 13px; }
|
||||
.efficiency-summary strong { margin-right: 10px; color: #d6dde7; font-size: 16px; }
|
||||
|
||||
Reference in New Issue
Block a user