feat: improve analysis and release v1.0.2

This commit is contained in:
2026-08-19 13:47:49 +09:00
parent 488d652e61
commit e259f82b69
30 changed files with 1222 additions and 205 deletions
+194 -56
View File
@@ -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>