feat: save analysis results and compare growth snapshots
Publish GitHub Pages output / publish (push) Successful in 46s

This commit is contained in:
2026-09-06 10:39:06 +09:00
parent b484daf956
commit 476a25caef
9 changed files with 510 additions and 8 deletions
+67 -3
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import AnalysisHistory from '$lib/components/AnalysisHistory.svelte';
import type { AnalysisCapture, AnalysisSettings, GrowthSnapshot } from '$lib/models/analysis-history.ts';
import { page } from '$app/stores';
import cardSummary from '$lib/data/cards-summary.json';
import SpecialNoteSummary from '$lib/components/SpecialNoteSummary.svelte';
@@ -107,6 +109,9 @@
let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let result: ExactOptimizationResult | null = null;
let resultCapture: AnalysisCapture | null = null;
let pendingCapture: AnalysisCapture | null = null;
const comparisonWorkers = new Set<Worker>();
let achievementProbabilities: ScoreAchievementProbabilities | undefined;
let errorMessage = '';
let analysisReady = false;
@@ -259,6 +264,7 @@
if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'result') {
result = event.data.result;
resultCapture = pendingCapture;
achievementProbabilities = event.data.achievementProbabilities;
running = false;
}
@@ -282,6 +288,7 @@
window.clearInterval(releaseClock);
window.removeEventListener('storage', syncStorage);
worker?.terminate();
for (const comparisonWorker of comparisonWorkers) comparisonWorker.terminate();
};
});
@@ -660,9 +667,11 @@
}
async function analyze(): Promise<void> {
if (running) return;
errorMessage = '';
clearConnectionOptimizationProposal();
result = null;
resultCapture = null;
achievementProbabilities = undefined;
if (!worker || !selectedSong || !selectedChart) return;
if (new Set(selectedCardIds).size !== 5) {
@@ -683,22 +692,76 @@
return optimizerCardWithBoard(card, index, unitMembers);
});
if (!Number.isFinite(overallPower) || overallPower <= 0) throw new Error('선택한 카드의 종합력을 계산하지 못했습니다.');
const chart = await loadExactSongChart(selectedChart.path);
running = true;
pendingCapture = structuredClone({
settings: presetSnapshot,
growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
title: selectedChart.title,
durationSeconds: selectedChart.durationSeconds,
overallPower,
analyzedAt: new Date().toISOString()
});
const noteScore = perfectNoteScore;
const songBonus = boardEffects.directSongScoreBonusPercent;
const chart = await loadExactSongChart(selectedChart.path);
progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) };
worker.postMessage({
type: 'optimize-exact',
cards,
chart,
perfectNoteScore,
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
perfectNoteScore: noteScore,
directSongScoreBonusPercent: songBonus,
includeAchievementProbabilities: true
});
} catch (error) {
running = false;
errorMessage = error instanceof Error ? error.message : '분석 입력을 준비하지 못했습니다.';
}
}
function latestGrowth(): GrowthSnapshot {
const value = JSON.parse(localStorage.getItem(USER_DATA_STORAGE_KEY) ?? '{}');
return structuredClone({ collection: value.collection ?? {}, memberBoards: value.memberBoards ?? {}, boardCards: value.boardCards ?? {}, memoryCount: value.memoryCount ?? 0, memberPowerUpPercent: value.memberPowerUpPercent ?? 0 });
}
async function evaluateHistory(settings: AnalysisSettings, growth: GrowthSnapshot): Promise<{ result: ExactOptimizationResult; overallPower: number }> {
const chartSummary = charts.find((chart) => chart.songId === settings.songId && chart.difficulty === settings.difficulty);
if (!chartSummary) throw new Error('현재 공개된 악곡 데이터에서 저장한 곡을 찾을 수 없습니다.');
const sources = settings.cardIds.map((id) => {
const card = allCardById.get(id);
if (!card) throw new Error('현재 데이터에서 저장된 편성 카드를 찾을 수 없습니다.');
return card;
});
const outfit = allCardById.get(settings.leaderOutfitCardId)?.details.costume;
if (!outfit) throw new Error('저장된 리더 의상을 찾을 수 없습니다.');
const effectiveBoards = boardsForSelectedCards(growth.memberBoards, settings.cardIds, settings.allBlueNodes ?? settings.cardIds.map(() => false));
const effects = calculateBoardEffects(boardSummary, effectiveBoards, sources, settings.leaderCharacterId, chartSummary, {
placements: growth.boardCards,
cards: cardSummary.cards.filter((card) => card.details.connect.length > 0).map((card) => ({ id: card.id, awakening: growth.collection[card.id]?.awakening ?? 0, connect: card.details.connect }))
}, {
generationBonusByCardId: Object.fromEntries(settings.cardIds.flatMap((id, i) => settings.generationBonusOverrides?.[i] == null ? [] : [[id, settings.generationBonusOverrides[i]]])),
...(settings.songScoreBonusOverride == null ? {} : { directSongScoreBonusPercent: settings.songScoreBonusOverride })
});
const power = calculateUnitPower(sources.map((card, i) => ({ card, awakening: settings.awakenings[i] })), outfit, effects, memoryParameterPermil(growth.memoryCount, memorySummary.effects), growth.memberPowerUpPercent);
const cards = sources.map((card, i) => {
const fixed = settings.fixedCooldownReductions?.[i];
const adapted = toOptimizerCard(card, settings.awakenings[i], fixed == null ? effects.allowedCooldownReductionsByCardId[card.id] : [fixed], sources);
return { ...adapted, active: { ...adapted.active, activationRateUpPercent: effects.activationRateUpByCardId[card.id] ?? 0 }, scoreSupportPercent: power.outfitScoreSupportPercent + effects.scoreSupportPercent };
});
const chart = await loadExactSongChart(chartSummary.path);
return new Promise((resolve, reject) => {
const comparisonWorker = createScoreOptimizerWorker();
comparisonWorkers.add(comparisonWorker);
const finish = () => { comparisonWorker.terminate(); comparisonWorkers.delete(comparisonWorker); };
comparisonWorker.onerror = () => { finish(); reject(new Error('성장 비교 작업을 실행하지 못했습니다.')); };
comparisonWorker.onmessage = (event) => {
if (event.data.type === 'result') { finish(); resolve({ result: event.data.result, overallPower: power.overallPower }); }
if (event.data.type === 'error') { finish(); reject(new Error(event.data.message)); }
};
comparisonWorker.postMessage({ type: 'optimize-exact', cards, chart, perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, chartSummary.scoreRatio), directSongScoreBonusPercent: effects.directSongScoreBonusPercent });
});
}
function resultCardName(cardId: string): string {
const card = cardById.get(cardId);
return card ? cardLabel(card) : cardId;
@@ -1025,6 +1088,7 @@
<div class="empty">조건을 선택하고 분석을 시작하면 최적 배치와 각 멤버의 쿨타임 감소량이 여기에 표시됩니다.</div>
{/if}
</section>
<AnalysisHistory capture={resultCapture} {result} cardName={resultCardName} currentGrowth={latestGrowth} evaluate={evaluateHistory} />
</main>
<style>