feat: add leader rating song recommendations

This commit is contained in:
2026-09-07 13:55:55 +09:00
parent 297eb1863c
commit 907c11b0e6
9 changed files with 625 additions and 15 deletions
+71 -12
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import LeaderSongRanking from '$lib/components/LeaderSongRanking.svelte';
import { analytics } from '$lib/analytics.ts';
import AnalysisHistory from '$lib/components/AnalysisHistory.svelte';
import type { AnalysisCapture, AnalysisSettings, GrowthSnapshot } from '$lib/models/analysis-history.ts';
@@ -21,6 +22,8 @@
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
export let mode: 'analysis' | 'rating' = 'analysis';
type CardRow = (typeof cardSummary.cards)[number];
type DifficultyKey = 'hard' | 'expert';
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
@@ -56,7 +59,7 @@
leaderOutfitCardId: string;
};
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
const ANALYSIS_STORAGE_KEY = mode === 'rating' ? 'holodori.rating.v1' : 'holodori.analysis.v2';
const ANALYSIS_PRESET_STORAGE_KEY = 'holodori.analysis.presets.v1';
const LEGACY_ANALYSIS_STORAGE_KEY = 'holodori.analysis.v1';
const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const;
@@ -104,12 +107,14 @@
let allBlueNodes = selectedCardIds.map(() => false);
let generationBonusOverrides: (number | null)[] = selectedCardIds.map(() => null);
let songScoreBonusOverride: number | null = null;
$: if (mode === 'rating' && songScoreBonusOverride !== 10) songScoreBonusOverride = 10;
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
let worker: Worker | null = null;
let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let result: ExactOptimizationResult | null = null;
let ratingResultKey = '';
let resultCapture: AnalysisCapture | null = null;
let pendingCapture: AnalysisCapture | null = null;
const comparisonWorkers = new Set<Worker>();
@@ -198,6 +203,15 @@
leaderCharacterId,
leaderOutfitCardId
} satisfies SavedAnalysisState;
$: ratingInputKey = JSON.stringify({
selectedCardIds, selectedAwakenings, fixedCooldownReductions, allBlueNodes,
generationBonusOverrides, songScoreBonusOverride, leaderCharacterId, leaderOutfitCardId,
collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent
});
$: if (mode === 'rating' && result && ratingResultKey !== ratingInputKey) {
result = null;
resultCapture = null;
}
$: if (analysisReady) saveAnalysisState({
schemaVersion: 2,
songId: selectedSongId,
@@ -251,7 +265,8 @@
}
try {
const currentState = localStorage.getItem(ANALYSIS_STORAGE_KEY);
const currentState = localStorage.getItem(ANALYSIS_STORAGE_KEY)
?? (mode === 'rating' ? localStorage.getItem('holodori.analysis.v2') : null);
const legacyState = currentState ? null : localStorage.getItem(LEGACY_ANALYSIS_STORAGE_KEY);
restoreAnalysisState(currentState ?? legacyState, Boolean(legacyState));
if (legacyState) localStorage.removeItem(LEGACY_ANALYSIS_STORAGE_KEY);
@@ -467,13 +482,13 @@
};
}
function connectionScoreContext(placements: BoardCards, chartSummary: NonNullable<typeof selectedChart>) {
function connectionScoreContext(placements: BoardCards, chartSummary: NonNullable<typeof selectedChart>, songBonusOverride = songScoreBonusOverride) {
const localBoardEffects = calculateBoardEffects(boardSummary, effectiveMemberBoards, selectedSourceCards, leaderCharacterId, chartSummary, {
placements,
cards: connectionCards
}, {
generationBonusByCardId,
...(songScoreBonusOverride === null ? {} : { directSongScoreBonusPercent: songScoreBonusOverride })
...(songBonusOverride === null ? {} : { directSongScoreBonusPercent: songBonusOverride })
});
const localUnitPower = calculateUnitPower(selectedCardIds.map((id, index) => ({
card: cardById.get(id)!, awakening: selectedAwakenings[index]
@@ -497,6 +512,33 @@
return { cards, boardEffects: localBoardEffects, unitPower: localUnitPower };
}
function ratingContext(chart: NonNullable<typeof selectedChart>) {
const context = connectionScoreContext(boardCards, chart, 10);
if (context.cards.length !== 5 || new Set(selectedSourceCards.map((card) => card.characterId)).size !== 5 ||
!Number.isFinite(context.unitPower.overallPower) || context.unitPower.overallPower <= 0) {
throw new Error('서로 다른 멤버 5명과 성장 설정을 확인해 주세요.');
}
return { cards: context.cards, overallPower: context.unitPower.overallPower,
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent };
}
async function showRatingResult(chart: NonNullable<typeof selectedChart>, rankingResult: ExactOptimizationResult, power: number) {
ratingResultKey = ratingInputKey;
selectedSongId = chart.songId;
difficulty = chart.difficulty as DifficultyKey;
result = rankingResult;
achievementProbabilities = undefined;
clearConnectionOptimizationProposal();
await tick();
resultCapture = structuredClone({
settings: presetSnapshot,
growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
title: chart.title, durationSeconds: chart.durationSeconds, overallPower: power,
analyzedAt: new Date().toISOString()
});
document.getElementById('result-title')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function connectionSocketName(memberId: string, socketId: string): string {
const member = boardMembers.find((item) => item.id === memberId);
const socket = member
@@ -853,14 +895,14 @@
}
</script>
<svelte:head><title>배치 분석 · 홀로도리 계산기</title></svelte:head>
<svelte:head><title>{mode === 'rating' ? '리더 레이팅' : '배치 분석'} · 홀로도리 계산기</title></svelte:head>
<main>
<header class="page-header">
<div>
<h1>배치 분석</h1>
<p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.</p>
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
<h1>{mode === 'rating' ? '리더 레이팅' : '배치 분석'}</h1>
<p class="page-description">{mode === 'rating' ? '입력한 편성으로 리더 가창곡의 Hard·Expert를 예상 레이팅 순으로 최대 20개 추천합니다. 결과는 5개씩 확인할 수 있습니다.' : '특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.'}</p>
{#if mode === 'analysis' && selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
{/if}
</div>
@@ -873,6 +915,7 @@
</div>
<div class="song-fields">
{#if mode === 'analysis'}
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
<label>난이도
<select bind:value={difficulty} on:change={() => result = null}>
@@ -894,6 +937,9 @@
<span class="in-game-note-score">인게임 1노트 {inGamePerfectNoteScore.toLocaleString('ko-KR')}점</span>
</div>
<p class="note-score-guide">인게임 1노트는 일반 PERFECT 기준으로 가창 보너스를 반영한 점수입니다. 스킬·콤보 보너스는 제외하며, 기본점수의 소수점을 유지해 보너스를 적용한 뒤 올림합니다.</p>
{:else}
<p class="note-score-guide"><strong>가창 보너스 10% 고정</strong> · 모든 추천 대상 곡에 동일하게 적용합니다. 종합력과 스킬 효과는 각 곡의 가창 조건에 맞춰 계산합니다.</p>
{/if}
</div>
<div class="card-fields">
@@ -951,7 +997,7 @@
</div>
{/each}
</div>
<p class="board-guide">‘파란 노드 전체’를 사용하지 않으면 사용자 데이터에 저장된 해당 멤버의 보드를 사용합니다. 기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 실제 보드 수치를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다.</p>
<p class="board-guide">‘파란 노드 전체’를 사용하지 않으면 사용자 데이터에 저장된 해당 멤버의 보드를 사용합니다. {mode === 'rating' ? '기수 보너스는 빈칸이면 실제 보드 수치를 사용합니다. 가창 보너스는 모든 곡에 10%를 적용합니다.' : '기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 실제 보드 수치를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다.'}</p>
<div class="leader-fields">
<label>리더
<select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}>
@@ -968,19 +1014,29 @@
<span>리더 의상 효과</span>
<p>{leaderOutfitDescription || '적용할 수 있는 의상이 없습니다.'}</p>
</div>
<p class="power-detail">최대 레벨 기준 · 기본 {unitPower.baseParameterTotal.toLocaleString('ko-KR')} + 개화 {unitPower.potentialTotal.toLocaleString('ko-KR')} + 패시브 {unitPower.passiveTotal.toLocaleString('ko-KR')} + 의상 {unitPower.outfitTotal.toLocaleString('ko-KR')} + 보드 {unitPower.boardTotal.toLocaleString('ko-KR')} + 메모리 {unitPower.memoryTotal.toLocaleString('ko-KR')} ({memoryCount}개) + 멤버 강화 {unitPower.memberPowerUpTotal.toLocaleString('ko-KR')} ({memberPowerUpPercent.toLocaleString('ko-KR')}%)</p>
{#if mode === 'analysis'}<p class="power-detail">최대 레벨 기준 · 기본 {unitPower.baseParameterTotal.toLocaleString('ko-KR')} + 개화 {unitPower.potentialTotal.toLocaleString('ko-KR')} + 패시브 {unitPower.passiveTotal.toLocaleString('ko-KR')} + 의상 {unitPower.outfitTotal.toLocaleString('ko-KR')} + 보드 {unitPower.boardTotal.toLocaleString('ko-KR')} + 메모리 {unitPower.memoryTotal.toLocaleString('ko-KR')} ({memoryCount}개) + 멤버 강화 {unitPower.memberPowerUpTotal.toLocaleString('ko-KR')} ({memberPowerUpPercent.toLocaleString('ko-KR')}%)</p>{/if}
<PresetManager storageKey={ANALYSIS_PRESET_STORAGE_KEY} snapshot={presetSnapshot} disabled={running} on:apply={applyPreset} />
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
{#if mode === 'analysis'}
<button class="analyze" type="button" disabled={running || candidateCount === 0} on:click={analyze}>
{running ? `분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 배치 분석'}
</button>
{#if running}<progress value={progress.evaluatedCandidates} max={progress.totalCandidates}></progress>{/if}
{/if}
</section>
{#if mode === 'rating'}
<LeaderSongRanking {charts} leaderId={leaderCharacterId} leaderName={leaderMember?.name ?? ''}
now={releaseNow} inputKey={ratingInputKey} disabled={!analysisReady || running || connectionOptimizationRunning || candidateCount === 0}
getContext={ratingContext} onSelect={showRatingResult} />
{/if}
{#if mode === 'analysis' || result}
<section class="panel result" aria-labelledby="result-title">
<div class="section-title"><div><span>02</span><h2 id="result-title">분석 결과</h2></div></div>
<div class="section-title"><div><span>{mode === 'rating' ? '03' : '02'}</span><h2 id="result-title">분석 결과</h2></div></div>
{#if mode === 'rating' && result}<p class="board-guide"><strong>{selectedChart?.title} · {difficulty === 'expert' ? 'Expert' : 'Hard'}</strong> · 종합력 {overallPower.toLocaleString('ko-KR')} · 가창 보너스 10%</p>{/if}
{#if result}
<div class="order-sections">
<section class="order-section">
@@ -997,12 +1053,14 @@
<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>
{#if mode === 'analysis' || achievementProbabilities}
<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>
{/if}
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
<div class="connection-optimizer-heading">
@@ -1112,6 +1170,7 @@
{/if}
</section>
<AnalysisHistory capture={resultCapture} {result} cardName={resultCardName} currentGrowth={latestGrowth} evaluate={evaluateHistory} />
{/if}
</main>
<style>