feat: add leader rating song recommendations
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user