feat: save analysis results and compare growth snapshots
Publish GitHub Pages output / publish (push) Successful in 46s
Publish GitHub Pages output / publish (push) Successful in 46s
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Repository instructions
|
# Repository instructions
|
||||||
|
|
||||||
|
## Commit and push shorthand
|
||||||
|
|
||||||
|
- When the user says "마무리 해" (or "마무리해"), treat it as authorization to verify the completed task, commit its changes, and push to the configured upstream.
|
||||||
|
- Otherwise, push only with the user's explicit authorization. Preserve unrelated pending changes and report what was committed and pushed.
|
||||||
|
- This shorthand does not authorize offline artifacts, version bumps, tags, or separate manual deployments.
|
||||||
|
|
||||||
## Online-only release policy
|
## Online-only release policy
|
||||||
|
|
||||||
- The supported product and release target is the online app, including the GitHub Pages deployment.
|
- The supported product and release target is the online app, including the GitHub Pages deployment.
|
||||||
|
|||||||
+13
-1
@@ -1,5 +1,17 @@
|
|||||||
---
|
---
|
||||||
updatedAt: 2026-09-05T10:20:00+09:00
|
updatedAt: 2026-09-06T10:13:00+09:00
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026년 9월 6일
|
||||||
|
|
||||||
|
### 배치 분석 결과 저장과 성장 비교
|
||||||
|
|
||||||
|
`NEW` 배치 분석 결과와 당시 성장 상태를 저장하고 다시 열어볼 수 있습니다. 같은 편성을 현재 성장 상태와 비교해 종합력·평균 점수·고점의 변화를 확인할 수 있으며, 기록과 성장 데이터의 백업·가져오기를 지원합니다.
|
||||||
|
|
||||||
|
### 카드 목록 액티브 확률 필터 수정
|
||||||
|
|
||||||
|
`FIXED` 카드 목록에서 액티브 확률 선택을 바꾸면 표시되는 카드가 즉시 갱신되도록 수정했습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026년 9월 5일
|
## 2026년 9월 5일
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import AnalysisResultView from './AnalysisResultView.svelte';
|
||||||
|
import { saveAnalysis, listAnalyses, readGrowth, deleteAnalysis, exportAnalyses, importAnalyses } from '$lib/storage/analysis-history.ts';
|
||||||
|
import { currentGrowthSettings, snapshotKey, type AnalysisCapture, type AnalysisRecord, type AnalysisSettings, type GrowthSnapshot } from '$lib/models/analysis-history.ts';
|
||||||
|
import type { ExactOptimizationResult } from '$lib/engine/types.ts';
|
||||||
|
export let capture: AnalysisCapture | null = null;
|
||||||
|
export let result: ExactOptimizationResult | null = null;
|
||||||
|
export let cardName: (id: string) => string;
|
||||||
|
export let currentGrowth: () => GrowthSnapshot;
|
||||||
|
export let evaluate: (settings: AnalysisSettings, growth: GrowthSnapshot) => Promise<{ result: ExactOptimizationResult; overallPower: number }>;
|
||||||
|
let name = '';
|
||||||
|
let records: AnalysisRecord[] = [];
|
||||||
|
let selected: AnalysisRecord | null = null;
|
||||||
|
let savedGrowth: GrowthSnapshot | null = null;
|
||||||
|
let busy = false;
|
||||||
|
let error = '';
|
||||||
|
let message = '';
|
||||||
|
let comparison: { before: { result: ExactOptimizationResult; overallPower: number }; after: { result: ExactOptimizationResult; overallPower: number }; changes: string[] } | null = null;
|
||||||
|
let phase = '';
|
||||||
|
let detail: ExactOptimizationResult | null = null;
|
||||||
|
onMount(() => { void refresh(); });
|
||||||
|
async function refresh() {
|
||||||
|
try { records = await listAnalyses(); } catch { error = '저장 기록을 열지 못했습니다. 브라우저 저장 공간 사용을 허용해 주세요.'; }
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
if (!capture || !result || busy) return;
|
||||||
|
busy = true; error = ''; message = '';
|
||||||
|
try { await saveAnalysis(capture, result, name); name = ''; await refresh(); message = '분석 결과와 당시 성장 상태를 저장했습니다.'; }
|
||||||
|
catch { error = '저장하지 못했습니다. 브라우저 저장 공간과 사용 권한을 확인해 주세요.'; }
|
||||||
|
finally { busy = false; }
|
||||||
|
}
|
||||||
|
async function open(record: AnalysisRecord) {
|
||||||
|
busy = true; error = ''; comparison = null; detail = null;
|
||||||
|
try { savedGrowth = await readGrowth(record.snapshotId); selected = record; }
|
||||||
|
catch (e) { error = String(e); }
|
||||||
|
finally { busy = false; }
|
||||||
|
}
|
||||||
|
async function remove(record: AnalysisRecord) {
|
||||||
|
if (!confirm(`‘${record.name}’ 기록을 삭제할까요?`)) return;
|
||||||
|
busy = true; error = '';
|
||||||
|
try { await deleteAnalysis(record.id); if (selected?.id === record.id) { selected = null; savedGrowth = null; comparison = null; } await refresh(); }
|
||||||
|
catch { error = '기록을 삭제하지 못했습니다.'; }
|
||||||
|
finally { busy = false; }
|
||||||
|
}
|
||||||
|
function differences(before: GrowthSnapshot, after: GrowthSnapshot, settings: AnalysisSettings): string[] {
|
||||||
|
const changes: string[] = [];
|
||||||
|
for (const [i, id] of settings.cardIds.entries()) {
|
||||||
|
const next = after.collection[id]?.awakening ?? 0;
|
||||||
|
if (settings.awakenings[i] !== next) changes.push(`${cardName(id)}: ${settings.awakenings[i]} → ${next}개화`);
|
||||||
|
}
|
||||||
|
if (before.memoryCount !== after.memoryCount) changes.push(`메모리: ${before.memoryCount} → ${after.memoryCount}개`);
|
||||||
|
if (before.memberPowerUpPercent !== after.memberPowerUpPercent) changes.push(`멤버 강화: ${before.memberPowerUpPercent} → ${after.memberPowerUpPercent}%`);
|
||||||
|
const boardKeys = new Set([...Object.keys(before.memberBoards), ...Object.keys(after.memberBoards)]);
|
||||||
|
let added = 0, removed = 0;
|
||||||
|
for (const id of boardKeys) {
|
||||||
|
const old = new Set(before.memberBoards[id] ?? []), next = new Set(after.memberBoards[id] ?? []);
|
||||||
|
added += [...next].filter((key) => !old.has(key)).length;
|
||||||
|
removed += [...old].filter((key) => !next.has(key)).length;
|
||||||
|
}
|
||||||
|
if (added || removed) changes.push(`전체 보드 활성화: 추가 ${added}개 / 해제 ${removed}개`);
|
||||||
|
if (snapshotKey(before.boardCards) !== snapshotKey(after.boardCards)) changes.push('보드 연결 카드 배치 변경');
|
||||||
|
if (snapshotKey(before.collection) !== snapshotKey(after.collection)) changes.push('보유 카드·개화 데이터 변경 (연결 카드 강화 포함)');
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
async function compare() {
|
||||||
|
if (!selected || !savedGrowth) return;
|
||||||
|
busy = true; error = ''; comparison = null;
|
||||||
|
try {
|
||||||
|
const growth = structuredClone(currentGrowth());
|
||||||
|
const settings = currentGrowthSettings(selected.settings, growth);
|
||||||
|
phase = '저장 당시 성장 상태를 현재 계산식으로 분석 중';
|
||||||
|
const before = await evaluate(selected.settings, savedGrowth);
|
||||||
|
phase = '현재 성장 상태 분석 중';
|
||||||
|
const after = await evaluate(settings, growth);
|
||||||
|
comparison = { before, after, changes: differences(savedGrowth, growth, selected.settings) };
|
||||||
|
} catch (e) { error = e instanceof Error ? e.message : '성장 비교를 완료하지 못했습니다.'; }
|
||||||
|
finally { busy = false; phase = ''; }
|
||||||
|
}
|
||||||
|
async function showDetail() {
|
||||||
|
if (!selected || !savedGrowth) return;
|
||||||
|
busy = true; error = ''; phase = '당시 성장으로 상세 결과 재계산 중';
|
||||||
|
try { detail = (await evaluate(selected.settings, savedGrowth)).result; }
|
||||||
|
catch (e) { error = e instanceof Error ? e.message : '상세 결과를 계산하지 못했습니다.'; }
|
||||||
|
finally { busy = false; phase = ''; }
|
||||||
|
}
|
||||||
|
async function backup() {
|
||||||
|
busy = true; error = '';
|
||||||
|
try {
|
||||||
|
const url = URL.createObjectURL(new Blob([await exportAnalyses()], { type: 'application/json' }));
|
||||||
|
const link = document.createElement('a'); link.href = url; link.download = 'holodori-analysis-history.json'; link.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
} catch { error = '기록 백업을 만들지 못했습니다.'; }
|
||||||
|
finally { busy = false; }
|
||||||
|
}
|
||||||
|
async function restore(event: Event) {
|
||||||
|
const input = event.currentTarget as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
busy = true; error = ''; message = '';
|
||||||
|
try {
|
||||||
|
if (file.size > 50 * 1024 * 1024) throw new Error('50MB 이하의 기록 백업 파일을 선택해 주세요.');
|
||||||
|
await importAnalyses(await file.text()); await refresh(); message = '기록을 추가했습니다. 현재 사용자 데이터는 유지됩니다.';
|
||||||
|
} catch (e) { error = e instanceof Error ? e.message : '기록을 복원하지 못했습니다.'; }
|
||||||
|
finally { busy = false; input.value = ''; }
|
||||||
|
}
|
||||||
|
function delta(a: number, b: number) { const d = b - a; return `${d >= 0 ? '+' : ''}${d.toLocaleString('ko-KR')} (${a ? `${d >= 0 ? '+' : ''}${(d / a * 100).toFixed(2)}%` : '비율 계산 불가'})`; }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="history" aria-label="분석 결과 저장 기록">
|
||||||
|
<div class="history-heading"><div><span class="eyebrow">MY RECORDS</span><h2>저장된 분석 결과</h2></div><span class="count-badge">{records.length}개 기록</span></div>
|
||||||
|
<p class="intro">오늘의 기록을 남기고, 다음 성장과 비교해 보세요.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<input aria-label="결과 저장 이름" placeholder={capture ? `${capture.title} · ${capture.settings.difficulty}` : '저장 이름'} bind:value={name} maxlength="120" />
|
||||||
|
<button class="primary" disabled={busy || !capture || !result} on:click={save}>+ 결과 저장</button>
|
||||||
|
<button disabled={busy || !records.length} on:click={backup}>기록 백업</button>
|
||||||
|
<label class="file-button" class:disabled={busy}>기록 가져오기 <input type="file" accept="application/json,.json" disabled={busy} on:change={restore} /></label>
|
||||||
|
</div>
|
||||||
|
<p class="hint">분석을 실행했던 시점의 설정과 성장 상태를 저장합니다. 기록 백업에는 성장 스냅샷도 포함됩니다. 일반 사용자 데이터 백업과 별도로 보관해 주세요.</p>
|
||||||
|
{#if error}<p role="alert">{error}</p>{/if}
|
||||||
|
{#if message}<p role="status">{message}</p>{/if}
|
||||||
|
{#if phase}<p role="status">{phase}…</p>{/if}
|
||||||
|
{#if !records.length}<div class="empty"><span aria-hidden="true">◇</span><h3>첫 분석을 기록해 보세요</h3><p>분석을 마친 뒤 이름을 붙여 저장하면<br />당시의 점수와 성장 상태를 다시 꺼내볼 수 있습니다.</p></div>{/if}
|
||||||
|
{#if records.length}<p class="hint">기록 {records.length}개 · 공유 성장 상태 {new Set(records.map((record) => record.snapshotId)).size}개</p>{/if}
|
||||||
|
<ul class="record-grid">
|
||||||
|
{#each records as record (record.id)}
|
||||||
|
<li class:selected={selected?.id === record.id}>
|
||||||
|
<button class="record" disabled={busy} on:click={() => open(record)} aria-expanded={selected?.id === record.id}>
|
||||||
|
<span class="record-meta"><span class="difficulty">{record.settings.difficulty === 'expert' ? 'EXPERT' : 'HARD'}</span><span>{new Date(record.analyzedAt).toLocaleDateString('ko-KR')}</span></span>
|
||||||
|
<strong class="record-name">{record.name}</strong><span class="song-name">{record.title}</span>
|
||||||
|
<span class="members">{#each record.settings.cardIds as id}<span>{cardName(id)}</span>{/each}</span>
|
||||||
|
<span class="record-scores"><span><small>평균 점수</small><b>{record.result.expectedScore.toLocaleString('ko-KR')}</b></span><span><small>이론상 고점</small><b>{record.result.theoreticalMaxScore.toLocaleString('ko-KR')}</b></span></span>
|
||||||
|
<span class="open-label">{selected?.id === record.id ? '확인 중' : '기록 자세히 보기'} <span aria-hidden="true">↗</span></span>
|
||||||
|
</button>
|
||||||
|
<button class="delete-button" disabled={busy} on:click={() => remove(record)} aria-label={`${record.name} 삭제`}>삭제</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if selected && savedGrowth}
|
||||||
|
<article>
|
||||||
|
<span class="eyebrow">SAVED RESULT</span><h3 class="detail-title">{selected.name}</h3>
|
||||||
|
<p class="hint">저장 당시 결과 · {new Date(selected.analyzedAt).toLocaleString('ko-KR')}</p>
|
||||||
|
<div class="score-cards"><div><span>종합력</span><strong>{selected.overallPower.toLocaleString('ko-KR')}</strong></div><div><span>평균 점수</span><strong>{selected.result.expectedScore.toLocaleString('ko-KR')}</strong></div><div><span>이론상 고점</span><strong>{selected.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div></div>
|
||||||
|
<h4>추천 스페셜 순서</h4>
|
||||||
|
<ol class="order-cards">{#each selected.result.order as id, i}<li><span class="slot">{i + 1}</span><strong>{cardName(id)}</strong><span class="cooldown">쿨타임 −{selected.result.cooldownReductions[id]}%</span></li>{/each}</ol>
|
||||||
|
<details><summary>당시 성장 상태·분석 설정</summary>
|
||||||
|
<p>{selected.settings.cardIds.map((id, i) => `${cardName(id)} ${selected!.settings.awakenings[i]}개화`).join(' / ')}</p>
|
||||||
|
<p>리더 의상 카드: {cardName(selected.settings.leaderOutfitCardId)}</p>
|
||||||
|
<p>메모리 {savedGrowth.memoryCount}개 · 멤버 강화 {savedGrowth.memberPowerUpPercent}% · 활성 보드 {Object.values(savedGrowth.memberBoards).reduce((n, ids) => n + ids.length, 0)}개</p>
|
||||||
|
<p>파란 노드 전체: {selected.settings.allBlueNodes?.map((v, i) => v ? `${i + 1}번` : '').filter(Boolean).join(', ') || '미사용'} · 기수 보너스 덮어쓰기: {selected.settings.generationBonusOverrides?.map((v) => v ?? '실제').join(' / ')} · 가창 보너스: {selected.settings.songScoreBonusOverride ?? '실제 보드'} </p>
|
||||||
|
</details>
|
||||||
|
<div class="compare-actions"><button class="primary" disabled={busy} on:click={compare}>현재 성장 상태와 비교</button>
|
||||||
|
<button disabled={busy} on:click={showDetail}>당시 성장으로 상세 재분석</button></div>
|
||||||
|
<p class="hint">곡·편성·의상·덮어쓰기·쿨타임 고정 조건을 유지합니다. 현재 보유 카드의 개화와 보드·연결 카드·메모리·멤버 강화를 적용합니다. 덮어쓴 항목의 실제 성장은 비교에 반영되지 않습니다.</p>
|
||||||
|
{#if comparison}
|
||||||
|
<div class="comparison-heading"><span class="eyebrow">GROWTH REPORT</span><h3>성장이 만든 점수 변화</h3></div>
|
||||||
|
<p>양쪽 모두 현재 계산식과 악곡 데이터로 재최적화한 비교입니다. 위에 보관된 원본 점수와 다를 수 있습니다.</p>
|
||||||
|
<div class="table-scroll"><table><thead><tr><th>항목</th><th>당시 성장</th><th>현재 성장</th><th>변화</th></tr></thead><tbody>
|
||||||
|
{#each [ ['종합력', comparison.before.overallPower, comparison.after.overallPower], ['평균 점수', comparison.before.result.expectedScore, comparison.after.result.expectedScore], ['이론상 고점', comparison.before.result.theoreticalMaxScore, comparison.after.result.theoreticalMaxScore] ] as row}
|
||||||
|
<tr><th>{row[0]}</th><td>{Number(row[1]).toLocaleString('ko-KR')}</td><td>{Number(row[2]).toLocaleString('ko-KR')}</td><td class:positive={Number(row[2]) > Number(row[1])} class:negative={Number(row[2]) < Number(row[1])}>{delta(Number(row[1]), Number(row[2]))}</td></tr>
|
||||||
|
{/each}
|
||||||
|
</tbody></table></div>
|
||||||
|
<h4>성장 데이터 변화</h4>
|
||||||
|
<div class="changes">{#each comparison.changes as change}<p><span aria-hidden="true">↗</span>{change}</p>{:else}<p>비교 대상의 성장 데이터 변화가 없습니다.</p>{/each}</div>
|
||||||
|
<h4>현재 성장의 추천 배치</h4>
|
||||||
|
<ol class="order-cards">{#each comparison.after.result.order as id, i}<li><span class="slot">{i + 1}</span><strong>{cardName(id)}</strong><span class="cooldown">쿨타임 −{comparison.after.result.cooldownReductions[id]}%</span></li>{/each}</ol>
|
||||||
|
{/if}
|
||||||
|
{#if detail}
|
||||||
|
<h4>당시 성장으로 상세 재분석한 결과</h4>
|
||||||
|
<p class="hint">노트별 그래프는 저장 용량을 줄이기 위해 보관하지 않습니다. 아래는 현재 계산식·악곡 데이터로 재계산한 결과이며 저장 원본 점수와 다를 수 있습니다.</p>
|
||||||
|
<AnalysisResultView result={detail} durationSeconds={selected.durationSeconds} {cardName} instanceId={`history-${selected.id}`} />
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.history { margin-top: 24px; padding: 28px; border: 1px solid #303541; border-radius: 18px; background: #181d24; color: #e8ebf1; }
|
||||||
|
.history-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; } .eyebrow { color: #e99cbd; font-size: 10px; font-weight: 800; letter-spacing: .16em; }
|
||||||
|
h2 { font-size: 24px; margin: 8px 0 0; letter-spacing: -.04em; } h3 { margin: 8px 0; } h4 { font-size: 14px; color: #b9c2ce; margin: 22px 0 12px; }
|
||||||
|
p { line-height: 1.65; color: #aeb7c5; } .intro { margin: 10px 0 24px; } .hint { font-size: 12px; color: #8f9aaa; }
|
||||||
|
.count-badge { padding: 8px 12px; border: 1px solid #664253; border-radius: 20px; color: #efb1ce; background: #342530; white-space: nowrap; font-size: 12px; }
|
||||||
|
.actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 14px; border: 1px solid #303744; background: #11161d; border-radius: 12px; }
|
||||||
|
.actions > input { flex: 1; min-width: 180px; }
|
||||||
|
button, input, .file-button { box-sizing: border-box; padding: 11px 14px; border: 1px solid #394353; border-radius: 8px; background: #202731; color: #dce2eb; font: inherit; font-size: 13px; }
|
||||||
|
input { background: #11161d; } button, .file-button { cursor: pointer; transition: border-color .15s, background .15s; }
|
||||||
|
button:hover:not(:disabled), .file-button:hover { border-color: #bd7899; background: #2e2935; }
|
||||||
|
button:focus-visible, input:focus-visible, .file-button:focus-within { outline: 2px solid #f0a0c4; outline-offset: 3px; }
|
||||||
|
button.primary { background: #e99abc; color: #241521; border-color: #e99abc; font-weight: 800; } button.primary:hover:not(:disabled) { background: #f5b1cf; }
|
||||||
|
button:disabled, .disabled { opacity: .45; cursor: default; }
|
||||||
|
.file-button { position: relative; display: inline-flex; align-items: center; overflow: hidden; } .file-button input { position: absolute; inset: 0; opacity: 0; width: 100%; cursor: pointer; }
|
||||||
|
.empty { text-align: center; border: 1px dashed #414554; border-radius: 12px; padding: 36px 16px; margin-top: 20px; } .empty > span { display: block; font-size: 36px; color: #d08bab; margin-bottom: 14px; } .empty h3 { font-size: 17px; } .empty p { font-size: 13px; }
|
||||||
|
.record-grid { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: 12px; margin: 16px 0 0; }
|
||||||
|
.record-grid > li { position: relative; min-width: 0; border: 1px solid #343d4a; border-radius: 13px; background: #121820; overflow: hidden; }
|
||||||
|
.record-grid > li.selected { border-color: #dc92b4; box-shadow: inset 0 3px #e99abc; background: #211e2a; }
|
||||||
|
button.record { width: 100%; height: 100%; padding: 20px; padding-bottom: 16px; text-align: left; display: flex; flex-direction: column; gap: 12px; border: 0; border-radius: 0; background: transparent; }
|
||||||
|
.record-meta { display: flex; align-items: center; justify-content: space-between; width: 100%; color: #8e99a9; font-size: 11px; }
|
||||||
|
.difficulty { padding: 4px 7px; border-radius: 5px; background: #392738; color: #f0acd0; letter-spacing: .06em; font-size: 9px; font-weight: 800; }
|
||||||
|
.record-name { font-size: 18px; letter-spacing: -.03em; overflow-wrap: anywhere; } .song-name { color: #a6b0be; font-size: 12px; margin-top: -6px; overflow-wrap: anywhere; }
|
||||||
|
.members { display: flex; flex-wrap: wrap; gap: 5px; margin: 3px 0 8px; } .members > span { padding: 4px 7px; background: #242b36; border-radius: 5px; color: #b9c3d2; font-size: 11px; }
|
||||||
|
.record-scores { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; width: 100%; padding-top: 14px; border-top: 1px solid #343541; margin-top: auto; }
|
||||||
|
.record-scores > span { display: grid; gap: 6px; } small { color: #9aa4b4; font-size: 11px; } .record-scores b { font-size: 20px; font-variant-numeric: tabular-nums; color: #edb0cd; letter-spacing: -.025em; }
|
||||||
|
.open-label { display: flex; gap: 10px; margin-top: 9px; font-size: 11px; color: #c891af; }
|
||||||
|
button.delete-button { position: absolute; bottom: 9px; right: 10px; padding: 7px 10px; font-size: 11px; color: #9ca6b5; background: transparent; border-color: transparent; }
|
||||||
|
article { margin-top: 28px; border-top: 1px solid #394353; padding-top: 26px; } .detail-title { font-size: 23px; letter-spacing: -.03em; }
|
||||||
|
.score-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 20px 0; } .score-cards > div { display: grid; gap: 10px; padding: 18px; background: #25212e; border: 1px solid #3b3044; border-radius: 10px; } .score-cards span { color: #b2a6bc; font-size: 12px; } .score-cards strong { color: #f2b0cf; font-size: clamp(18px, 2.1vw, 26px); font-variant-numeric: tabular-nums; }
|
||||||
|
.order-cards { list-style: none; padding: 0; display: grid; gap: 7px; } .order-cards li { display: flex; align-items: center; gap: 12px; background: #121820; border: 1px solid #303946; padding: 10px 12px; border-radius: 9px; } .slot { display: grid; place-items: center; width: 26px; height: 26px; flex-shrink: 0; color: #261724; background: #dd97b9; font-weight: 800; border-radius: 7px; } .order-cards strong { font-size: 13px; } .cooldown { margin-left: auto; color: #a9b5c5; font-size: 11px; white-space: nowrap; }
|
||||||
|
details { margin: 18px 0; border: 1px solid #333d4a; border-radius: 10px; padding: 14px; background: #141a22; } summary { cursor: pointer; font-size: 13px; color: #bbc6d4; } details p { font-size: 12px; }
|
||||||
|
.compare-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; } .comparison-heading { margin-top: 30px; padding-top: 22px; border-top: 1px solid #394353; }
|
||||||
|
.table-scroll { overflow-x: auto; border: 1px solid #343e4c; border-radius: 12px; } table { width: 100%; border-collapse: collapse; white-space: nowrap; font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||||
|
th, td { padding: 16px; text-align: right; border-bottom: 1px solid #303846; } th:first-child { text-align: left; } thead { background: #232b36; color: #b7c3d3; font-size: 11px; } tbody { background: #131a22; } tbody tr:last-child > * { border-bottom: 0; } td:last-child { font-weight: 800; } td.positive { color: #89d5aa; background: #172920; } td.negative { color: #f1a4ae; background: #2c1f28; }
|
||||||
|
.changes { display: grid; gap: 7px; } .changes p { display: flex; align-items: center; gap: 10px; padding: 10px 14px; margin: 0; background: #202630; border-radius: 8px; font-size: 12px; } .changes span { color: #d49abc; }
|
||||||
|
[role=alert] { color: #f3a2ae; padding: 12px; border-radius: 8px; background: #31212b; } [role=status] { color: #a6d5bf; font-size: 13px; }
|
||||||
|
@media(max-width: 600px) { .history { padding: 18px; } h2 { font-size: 20px; } .actions > input { flex-basis: 100%; min-width: 0; } .score-cards { grid-template-columns: 1fr; } .score-cards > div { display: flex; align-items: center; justify-content: space-between; padding: 14px; } .order-cards li { flex-wrap: wrap; gap: 8px; } .record-scores b { font-size: 22px; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { compactResult, currentGrowthSettings, snapshotKey, type AnalysisSettings, type GrowthSnapshot } from '../models/analysis-history.ts';
|
||||||
|
import { parseAnalysisArchive } from '../storage/analysis-history.ts';
|
||||||
|
import type { ExactOptimizationResult } from './types.ts';
|
||||||
|
|
||||||
|
const growth: GrowthSnapshot = { collection: { a: { owned: true, awakening: 2 } }, memberBoards: { a: ['B1', 'G1'] }, boardCards: {}, memoryCount: 10, memberPowerUpPercent: 1 };
|
||||||
|
test('동일한 성장 상태는 입력 순서와 무관하게 공유하고 성장 차이는 구분한다', () => {
|
||||||
|
const reordered = { ...growth, memberBoards: { a: ['G1', 'B1'] } };
|
||||||
|
assert.equal(snapshotKey(growth), snapshotKey(reordered));
|
||||||
|
assert.notEqual(snapshotKey(growth), snapshotKey({ ...growth, memoryCount: 11 }));
|
||||||
|
});
|
||||||
|
test('현재 성장 비교는 저장 조건을 유지하고 보유 개화만 교체한다', () => {
|
||||||
|
const settings: AnalysisSettings = { songId: 'song', difficulty: 'expert', cardIds: ['a'], awakenings: [0], allBlueNodes: [true], generationBonusOverrides: [900], leaderCharacterId: 'a', leaderOutfitCardId: 'a' };
|
||||||
|
const current = currentGrowthSettings(settings, growth);
|
||||||
|
assert.deepEqual(current.awakenings, [2]); assert.deepEqual(settings.awakenings, [0]);
|
||||||
|
assert.deepEqual(current.generationBonusOverrides, [900]); assert.deepEqual(current.allBlueNodes, [true]);
|
||||||
|
assert.throws(() => currentGrowthSettings(settings, { ...growth, collection: {} }), /보유/);
|
||||||
|
});
|
||||||
|
test('기록은 노트별 배열을 제외하고 원본과 독립적으로 보존한다', () => {
|
||||||
|
const source = { notes: [{ timeSeconds: 1 }], scoreBySecond: [2], order: ['a'], expectedScore: 123 } as unknown as ExactOptimizationResult;
|
||||||
|
const saved = compactResult(source);
|
||||||
|
source.order = ['b'];
|
||||||
|
assert.deepEqual(saved.order, ['a']); assert.equal(saved.expectedScore, 123);
|
||||||
|
assert.equal('notes' in saved, false); assert.equal('scoreBySecond' in saved, false);
|
||||||
|
});
|
||||||
|
test('기록 백업은 잘못된 구조와 누락된 성장 참조를 거부한다', () => {
|
||||||
|
assert.throws(() => parseAnalysisArchive('{}'));
|
||||||
|
assert.throws(() => parseAnalysisArchive(JSON.stringify({ schemaVersion: 1, snapshots: [], records: [{ settings: {}, result: {}, snapshotId: 'missing' }] })));
|
||||||
|
assert.deepEqual(parseAnalysisArchive('{"schemaVersion":1,"snapshots":[],"records":[]}').records, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('성장 상태를 공유하는 기록 백업을 손실 없이 읽고 잘못된 개화를 거부한다', () => {
|
||||||
|
const ids = ['a', 'b', 'c', 'd', 'e'];
|
||||||
|
const record = {
|
||||||
|
id: 'record', name: '기록', title: '곡', analyzedAt: '2026-09-06T00:00:00Z', snapshotId: 'snapshot', overallPower: 1000, durationSeconds: 120,
|
||||||
|
settings: { songId: 'song', difficulty: 'expert', cardIds: ids, awakenings: [0, 1, 2, 3, 4], leaderCharacterId: 'a', leaderOutfitCardId: 'a' },
|
||||||
|
result: { expectedScore: 100, theoreticalMaxScore: 120, baselineScore: 50, perfectNoteScore: 10, order: ids, cooldownReductions: Object.fromEntries(ids.map((id) => [id, 0])) }
|
||||||
|
};
|
||||||
|
const archive = { schemaVersion: 1, snapshots: [{ id: 'snapshot', growth }], records: [record, { ...record, id: 'second' }] };
|
||||||
|
const parsed = parseAnalysisArchive(JSON.stringify(archive));
|
||||||
|
assert.equal(parsed.records.length, 2); assert.equal(parsed.snapshots.length, 1);
|
||||||
|
assert.deepEqual(parsed.snapshots[0].growth, growth);
|
||||||
|
record.settings.awakenings[0] = 8;
|
||||||
|
assert.throws(() => parseAnalysisArchive(JSON.stringify(archive)));
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { compile } from 'svelte/compiler';
|
||||||
|
|
||||||
|
test('액티브 확률 선택 변경이 카드 목록 재계산을 유발한다', () => {
|
||||||
|
const filename = new URL('../../routes/cards/+page.svelte', import.meta.url);
|
||||||
|
const source = readFileSync(filename, 'utf8');
|
||||||
|
const code = compile(source, { filename: filename.pathname, generate: 'client' }).js.code;
|
||||||
|
const effect = code.split('$.legacy_pre_effect(').find((part) => part.includes('$.set(filteredCards,'));
|
||||||
|
assert.ok(effect, '카드 목록 계산 효과가 있어야 한다');
|
||||||
|
const dependencies = effect.split('() => {')[0];
|
||||||
|
assert.ok(dependencies.includes('$.get(selectedActiveProbabilities)'), '확률 선택값을 반응형 의존성으로 추적해야 한다');
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { CooldownReduction, ExactOptimizationResult } from '../engine/types.ts';
|
||||||
|
|
||||||
|
export type AnalysisSettings = {
|
||||||
|
songId: string; difficulty: 'hard' | 'expert'; cardIds: string[]; awakenings: number[];
|
||||||
|
fixedCooldownReductions?: (CooldownReduction | null)[];
|
||||||
|
allBlueNodes?: boolean[]; generationBonusOverrides?: (number | null)[];
|
||||||
|
songScoreBonusOverride?: number | null; leaderCharacterId: string; leaderOutfitCardId: string;
|
||||||
|
};
|
||||||
|
export type GrowthSnapshot = {
|
||||||
|
collection: Record<string, { owned?: boolean; awakening?: number }>;
|
||||||
|
memberBoards: Record<string, string[]>;
|
||||||
|
boardCards: Record<string, Record<string, string>>;
|
||||||
|
memoryCount: number; memberPowerUpPercent: number;
|
||||||
|
};
|
||||||
|
export type StoredResult = Omit<ExactOptimizationResult, 'notes' | 'scoreBySecond'>;
|
||||||
|
export type AnalysisCapture = {
|
||||||
|
settings: AnalysisSettings; growth: GrowthSnapshot; title: string; durationSeconds: number;
|
||||||
|
overallPower: number; analyzedAt: string;
|
||||||
|
};
|
||||||
|
export type AnalysisRecord = Omit<AnalysisCapture, 'growth'> & {
|
||||||
|
id: string; name: string; snapshotId: string; result: StoredResult;
|
||||||
|
};
|
||||||
|
export function compactResult(result: ExactOptimizationResult): StoredResult {
|
||||||
|
const { notes, scoreBySecond, ...stored } = result;
|
||||||
|
return structuredClone(stored);
|
||||||
|
}
|
||||||
|
// Object insertion order and board selection order must not create duplicate snapshots.
|
||||||
|
export function snapshotKey(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) return JSON.stringify(value.map((item) => snapshotKey(item)).sort());
|
||||||
|
if (value && typeof value === 'object') return JSON.stringify(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, snapshotKey(item)]));
|
||||||
|
return JSON.stringify(value) ?? 'null';
|
||||||
|
}
|
||||||
|
export function currentGrowthSettings(settings: AnalysisSettings, growth: GrowthSnapshot): AnalysisSettings {
|
||||||
|
const missing = settings.cardIds.filter((id) => !growth.collection[id]?.owned);
|
||||||
|
if (missing.length) throw new Error('현재 보유 데이터에 없는 편성 카드가 있습니다. 보유 카드와 개화를 등록한 후 비교해 주세요.');
|
||||||
|
return { ...structuredClone(settings), awakenings: settings.cardIds.map((id) => growth.collection[id]?.awakening ?? 0) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { compactResult, snapshotKey, type AnalysisCapture, type AnalysisRecord, type GrowthSnapshot } from '../models/analysis-history.ts';
|
||||||
|
import type { ExactOptimizationResult } from '../engine/types.ts';
|
||||||
|
|
||||||
|
type SnapshotRow = { id: string; growth: GrowthSnapshot };
|
||||||
|
type Archive = { schemaVersion: 1; records: AnalysisRecord[]; snapshots: SnapshotRow[] };
|
||||||
|
export function parseAnalysisArchive(text: string): Archive {
|
||||||
|
const archive = JSON.parse(text) as Archive;
|
||||||
|
const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||||
|
const finite = (v: unknown) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
||||||
|
const strings = (v: unknown): v is string[] => Array.isArray(v) && v.every((s) => typeof s === 'string');
|
||||||
|
const invalid = () => { throw new Error('지원하지 않거나 손상된 분석 기록 백업입니다.'); };
|
||||||
|
if (!object(archive) || archive.schemaVersion !== 1 || !Array.isArray(archive.records) || !Array.isArray(archive.snapshots)) return invalid();
|
||||||
|
for (const row of archive.snapshots) {
|
||||||
|
if (!object(row) || typeof row.id !== 'string' || !object(row.growth)) return invalid();
|
||||||
|
const g = row.growth;
|
||||||
|
if (!object(g.collection) || !object(g.memberBoards) || !object(g.boardCards) || !finite(g.memoryCount) || !finite(g.memberPowerUpPercent)) return invalid();
|
||||||
|
if (!Object.values(g.collection).every((v) => object(v) && (v.owned === undefined || typeof v.owned === 'boolean') && (v.awakening === undefined || (Number.isInteger(v.awakening) && Number(v.awakening) >= 0 && Number(v.awakening) <= 5)))) return invalid();
|
||||||
|
if (!Object.values(g.memberBoards).every(strings) || !Object.values(g.boardCards).every((v) => object(v) && Object.values(v).every((s) => typeof s === 'string'))) return invalid();
|
||||||
|
}
|
||||||
|
const snapshots = new Set(archive.snapshots.map((s) => s.id));
|
||||||
|
if (snapshots.size !== archive.snapshots.length) return invalid();
|
||||||
|
for (const record of archive.records) {
|
||||||
|
if (!object(record) || !object(record.settings) || !object(record.result) || !snapshots.has(record.snapshotId)) return invalid();
|
||||||
|
if (![record.id, record.name, record.title, record.analyzedAt].every((s) => typeof s === 'string') || !Number.isFinite(Date.parse(record.analyzedAt))) return invalid();
|
||||||
|
const s = record.settings, r = record.result;
|
||||||
|
if (!strings(s.cardIds) || s.cardIds.length !== 5 || new Set(s.cardIds).size !== 5 || !Array.isArray(s.awakenings) || s.awakenings.length !== 5 || !s.awakenings.every((n) => Number.isInteger(n) && n >= 0 && n <= 5)) return invalid();
|
||||||
|
if (typeof s.songId !== 'string' || !['hard', 'expert'].includes(s.difficulty) || typeof s.leaderCharacterId !== 'string' || typeof s.leaderOutfitCardId !== 'string') return invalid();
|
||||||
|
if (s.allBlueNodes && (s.allBlueNodes.length !== 5 || !s.allBlueNodes.every((v) => typeof v === 'boolean'))) return invalid();
|
||||||
|
if (s.fixedCooldownReductions && (s.fixedCooldownReductions.length !== 5 || !s.fixedCooldownReductions.every((v) => v === null || [0, 4, 8, 12].includes(v)))) return invalid();
|
||||||
|
if (s.generationBonusOverrides && (s.generationBonusOverrides.length !== 5 || !s.generationBonusOverrides.every((v) => v === null || (finite(v) && v <= 900)))) return invalid();
|
||||||
|
if (s.songScoreBonusOverride != null && (!finite(s.songScoreBonusOverride) || s.songScoreBonusOverride > 10)) return invalid();
|
||||||
|
if (![record.overallPower, record.durationSeconds, r.expectedScore, r.theoreticalMaxScore, r.baselineScore, r.perfectNoteScore].every(finite)) return invalid();
|
||||||
|
if (!strings(r.order) || r.order.length !== 5 || new Set(r.order).size !== 5 || !r.order.every((id) => s.cardIds.includes(id)) || !object(r.cooldownReductions) || !r.order.every((id) => [0, 4, 8, 12].includes(r.cooldownReductions[id]))) return invalid();
|
||||||
|
}
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
function openDatabase(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open('holodori-analysis-history', 1);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
request.result.createObjectStore('records', { keyPath: 'id' });
|
||||||
|
request.result.createObjectStore('snapshots', { keyPath: 'id' });
|
||||||
|
};
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onblocked = () => reject(new Error('다른 탭을 닫은 후 저장 기록을 다시 열어 주세요.'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function requestValue<T>(request: IDBRequest<T>): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
|
||||||
|
}
|
||||||
|
async function transaction<T>(mode: IDBTransactionMode, action: (tx: IDBTransaction) => Promise<T>): Promise<T> {
|
||||||
|
const db = await openDatabase();
|
||||||
|
try {
|
||||||
|
const tx = db.transaction(['records', 'snapshots'], mode);
|
||||||
|
const done = new Promise<void>((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onabort = () => reject(tx.error ?? new Error('저장 작업이 취소되었습니다.')); tx.onerror = () => reject(tx.error); });
|
||||||
|
const [result] = await Promise.all([action(tx), done]);
|
||||||
|
return result;
|
||||||
|
} finally { db.close(); }
|
||||||
|
}
|
||||||
|
export async function saveAnalysis(capture: AnalysisCapture, result: ExactOptimizationResult, name: string): Promise<void> {
|
||||||
|
const snapshotId = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(snapshotKey(capture.growth)))), (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
const { growth, ...metadata } = structuredClone(capture);
|
||||||
|
const record: AnalysisRecord = { ...metadata, id: crypto.randomUUID(), snapshotId, name: name.trim().slice(0, 120) || capture.title, result: compactResult(result) };
|
||||||
|
await transaction('readwrite', async (tx) => {
|
||||||
|
await Promise.all([requestValue(tx.objectStore('snapshots').put({ id: snapshotId, growth })), requestValue(tx.objectStore('records').add(record))]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function listAnalyses(): Promise<AnalysisRecord[]> {
|
||||||
|
return (await transaction('readonly', (tx) => requestValue<AnalysisRecord[]>(tx.objectStore('records').getAll()))).sort((a, b) => b.analyzedAt.localeCompare(a.analyzedAt));
|
||||||
|
}
|
||||||
|
export async function readGrowth(id: string): Promise<GrowthSnapshot> {
|
||||||
|
const row = await transaction('readonly', (tx) => requestValue<SnapshotRow | undefined>(tx.objectStore('snapshots').get(id)));
|
||||||
|
if (!row) throw new Error('저장된 성장 데이터를 찾을 수 없습니다.');
|
||||||
|
return row.growth;
|
||||||
|
}
|
||||||
|
export async function deleteAnalysis(id: string): Promise<void> {
|
||||||
|
await transaction('readwrite', async (tx) => {
|
||||||
|
const records = tx.objectStore('records');
|
||||||
|
await requestValue(records.delete(id));
|
||||||
|
const remaining = await requestValue<AnalysisRecord[]>(records.getAll());
|
||||||
|
const used = new Set(remaining.map((record) => record.snapshotId));
|
||||||
|
const snapshots = tx.objectStore('snapshots');
|
||||||
|
const keys = await requestValue(snapshots.getAllKeys());
|
||||||
|
await Promise.all(keys.filter((key) => !used.has(String(key))).map((key) => requestValue(snapshots.delete(key))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function exportAnalyses(): Promise<string> {
|
||||||
|
return transaction('readonly', async (tx) => {
|
||||||
|
const [records, snapshots] = await Promise.all([requestValue(tx.objectStore('records').getAll()), requestValue(tx.objectStore('snapshots').getAll())]);
|
||||||
|
return JSON.stringify({ schemaVersion: 1, records, snapshots } satisfies Archive);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function importAnalyses(text: string): Promise<void> {
|
||||||
|
const archive = parseAnalysisArchive(text);
|
||||||
|
const referenced = new Set(archive.records.map((r) => r.snapshotId));
|
||||||
|
const snapshots = await Promise.all(archive.snapshots.filter((s) => referenced.has(s.id)).map(async (s) => ({
|
||||||
|
oldId: s.id, growth: s.growth,
|
||||||
|
id: Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(snapshotKey(s.growth)))), (b) => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
})));
|
||||||
|
const ids = new Map(snapshots.map((s) => [s.oldId, s.id]));
|
||||||
|
await transaction('readwrite', async (tx) => {
|
||||||
|
await Promise.all([
|
||||||
|
...snapshots.map(({ id, growth }) => requestValue(tx.objectStore('snapshots').put({ id, growth }))),
|
||||||
|
...archive.records.map((r) => requestValue(tx.objectStore('records').add({ ...r, id: crypto.randomUUID(), snapshotId: ids.get(r.snapshotId) })))
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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 { page } from '$app/stores';
|
||||||
import cardSummary from '$lib/data/cards-summary.json';
|
import cardSummary from '$lib/data/cards-summary.json';
|
||||||
import SpecialNoteSummary from '$lib/components/SpecialNoteSummary.svelte';
|
import SpecialNoteSummary from '$lib/components/SpecialNoteSummary.svelte';
|
||||||
@@ -107,6 +109,9 @@
|
|||||||
let running = false;
|
let running = false;
|
||||||
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
|
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
|
||||||
let result: ExactOptimizationResult | null = null;
|
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 achievementProbabilities: ScoreAchievementProbabilities | undefined;
|
||||||
let errorMessage = '';
|
let errorMessage = '';
|
||||||
let analysisReady = false;
|
let analysisReady = false;
|
||||||
@@ -259,6 +264,7 @@
|
|||||||
if (event.data.type === 'progress') progress = event.data.progress;
|
if (event.data.type === 'progress') progress = event.data.progress;
|
||||||
if (event.data.type === 'result') {
|
if (event.data.type === 'result') {
|
||||||
result = event.data.result;
|
result = event.data.result;
|
||||||
|
resultCapture = pendingCapture;
|
||||||
achievementProbabilities = event.data.achievementProbabilities;
|
achievementProbabilities = event.data.achievementProbabilities;
|
||||||
running = false;
|
running = false;
|
||||||
}
|
}
|
||||||
@@ -282,6 +288,7 @@
|
|||||||
window.clearInterval(releaseClock);
|
window.clearInterval(releaseClock);
|
||||||
window.removeEventListener('storage', syncStorage);
|
window.removeEventListener('storage', syncStorage);
|
||||||
worker?.terminate();
|
worker?.terminate();
|
||||||
|
for (const comparisonWorker of comparisonWorkers) comparisonWorker.terminate();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -660,9 +667,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function analyze(): Promise<void> {
|
async function analyze(): Promise<void> {
|
||||||
|
if (running) return;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
clearConnectionOptimizationProposal();
|
clearConnectionOptimizationProposal();
|
||||||
result = null;
|
result = null;
|
||||||
|
resultCapture = null;
|
||||||
achievementProbabilities = undefined;
|
achievementProbabilities = undefined;
|
||||||
if (!worker || !selectedSong || !selectedChart) return;
|
if (!worker || !selectedSong || !selectedChart) return;
|
||||||
if (new Set(selectedCardIds).size !== 5) {
|
if (new Set(selectedCardIds).size !== 5) {
|
||||||
@@ -683,22 +692,76 @@
|
|||||||
return optimizerCardWithBoard(card, index, unitMembers);
|
return optimizerCardWithBoard(card, index, unitMembers);
|
||||||
});
|
});
|
||||||
if (!Number.isFinite(overallPower) || overallPower <= 0) throw new Error('선택한 카드의 종합력을 계산하지 못했습니다.');
|
if (!Number.isFinite(overallPower) || overallPower <= 0) throw new Error('선택한 카드의 종합력을 계산하지 못했습니다.');
|
||||||
const chart = await loadExactSongChart(selectedChart.path);
|
|
||||||
running = true;
|
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) };
|
progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) };
|
||||||
worker.postMessage({
|
worker.postMessage({
|
||||||
type: 'optimize-exact',
|
type: 'optimize-exact',
|
||||||
cards,
|
cards,
|
||||||
chart,
|
chart,
|
||||||
perfectNoteScore,
|
perfectNoteScore: noteScore,
|
||||||
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
|
directSongScoreBonusPercent: songBonus,
|
||||||
includeAchievementProbabilities: true
|
includeAchievementProbabilities: true
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
running = false;
|
||||||
errorMessage = error instanceof Error ? error.message : '분석 입력을 준비하지 못했습니다.';
|
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 {
|
function resultCardName(cardId: string): string {
|
||||||
const card = cardById.get(cardId);
|
const card = cardById.get(cardId);
|
||||||
return card ? cardLabel(card) : cardId;
|
return card ? cardLabel(card) : cardId;
|
||||||
@@ -1025,6 +1088,7 @@
|
|||||||
<div class="empty">조건을 선택하고 분석을 시작하면 최적 배치와 각 멤버의 쿨타임 감소량이 여기에 표시됩니다.</div>
|
<div class="empty">조건을 선택하고 분석을 시작하면 최적 배치와 각 멤버의 쿨타임 감소량이 여기에 표시됩니다.</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
<AnalysisHistory capture={resultCapture} {result} cardName={resultCardName} currentGrowth={latestGrowth} evaluate={evaluateHistory} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
&& selectedStats.includes(card.highestStat)
|
&& selectedStats.includes(card.highestStat)
|
||||||
&& matchesSkillCondition(selectedActiveConditions, card.details.active ?? [])
|
&& matchesSkillCondition(selectedActiveConditions, card.details.active ?? [])
|
||||||
&& selectedSpecialConditions.includes(skillConditionStatus(card.details.special))
|
&& selectedSpecialConditions.includes(skillConditionStatus(card.details.special))
|
||||||
&& matchesActiveProbability(card.details.active ?? [])
|
&& matchesActiveProbability(selectedActiveProbabilities, card.details.active ?? [])
|
||||||
&& selectedCardVariants.includes(cardVariantStatus(card))
|
&& selectedCardVariants.includes(cardVariantStatus(card))
|
||||||
&& generationMemberships(card.generation).some((generation) => selectedGenerations.includes(generation))
|
&& generationMemberships(card.generation).some((generation) => selectedGenerations.includes(generation))
|
||||||
),
|
),
|
||||||
@@ -204,11 +204,11 @@
|
|||||||
return levels.some((level) => level.probabilityPercent === 55) ? '높음' : '중간';
|
return levels.some((level) => level.probabilityPercent === 55) ? '높음' : '중간';
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesActiveProbability(levels: SkillLevel[]): boolean {
|
function matchesActiveProbability(selected: ActiveProbability[], levels: SkillLevel[]): boolean {
|
||||||
const status = activeProbabilityStatus(levels);
|
const status = activeProbabilityStatus(levels);
|
||||||
return status === null
|
return status === null
|
||||||
? selectedActiveProbabilities.length === activeProbabilityOptions.length
|
? selected.length === activeProbabilityOptions.length
|
||||||
: selectedActiveProbabilities.includes(status);
|
: selected.includes(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCard(cardId: string) {
|
function toggleCard(cardId: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user