fix: preserve analysis results and flag stale user data
Publish GitHub Pages output / publish (push) Successful in 51s

This commit is contained in:
2026-09-09 18:00:33 +09:00
parent eb294ff5e3
commit 28035110f9
5 changed files with 234 additions and 46 deletions
+26 -25
View File
@@ -114,7 +114,11 @@
let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let result: ExactOptimizationResult | null = null;
let simulationContext: { cards: OptimizerCard[]; chart: ExactSongChart; options: ExactScoreOptions } | null = null;
type SimulationContext = { cards: OptimizerCard[]; chart: ExactSongChart; options: ExactScoreOptions };
let simulationContext: SimulationContext | null = null;
let pendingSimulationContext: SimulationContext | null = null;
let resultInputKey = '';
let pendingInputKey = '';
let simulationBase: ExactOptimizationResult | null = null;
let simulatedResult: ExactOptimizationResult | null = null;
let excludedActiveWindows: SkillWindow[] = [];
@@ -226,7 +230,7 @@
$: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null);
$: inGamePerfectNoteScore = finalNoteScore(perfectNoteScore, boardEffects.directSongScoreBonusPercent);
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'formula-v1' ? '곡별 공식' : selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
$: timelineDuration = selectedChart?.durationSeconds ?? 0;
$: timelineDuration = resultCapture?.durationSeconds ?? selectedChart?.durationSeconds ?? 0;
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(displayedResult?.effectiveActiveWindows ?? []);
$: timelineTicks = buildTimelineTicks(timelineDuration);
$: timelineWidth = Math.max(720, timelineDuration * 6);
@@ -263,6 +267,9 @@
result = null;
resultCapture = null;
}
$: currentAnalysisKey = JSON.stringify({ songId: selectedSongId, difficulty, input: ratingInputKey });
$: resultStale = mode === 'analysis' && Boolean(result) && resultInputKey !== currentAnalysisKey;
$: pendingStale = mode === 'analysis' && running && pendingInputKey !== currentAnalysisKey;
$: if (analysisReady) saveAnalysisState({
schemaVersion: 2,
songId: selectedSongId,
@@ -333,6 +340,8 @@
if (event.data.type === 'result') {
result = event.data.result;
resultCapture = pendingCapture;
resultInputKey = pendingInputKey;
simulationContext = pendingSimulationContext;
achievementProbabilities = event.data.achievementProbabilities;
running = false;
}
@@ -342,10 +351,9 @@
}
};
const syncStorage = (event: StorageEvent) => {
if (event.key !== USER_DATA_STORAGE_KEY) return;
if (event.storageArea !== localStorage || (event.key !== USER_DATA_STORAGE_KEY && event.key !== null)) return;
try {
applyStoredUserData(event.newValue);
result = null;
applyStoredUserData(localStorage.getItem(USER_DATA_STORAGE_KEY));
} catch {
// 다른 탭에서 쓰기가 끝나지 않은 순간에는 기존 상태를 유지한다.
}
@@ -415,8 +423,6 @@
function applyPreset(event: CustomEvent<unknown>): void {
try {
restoreAnalysisState(JSON.stringify(event.detail));
result = null;
achievementProbabilities = undefined;
errorMessage = '';
} catch {
errorMessage = '이 프리셋의 분석 조건을 불러오지 못했습니다.';
@@ -426,7 +432,6 @@
function updateAwakening(slot: number, awakening: number): void {
const normalized = normalizeAwakening(awakening);
selectedAwakenings = selectedAwakenings.map((value, index) => index === slot ? normalized : value);
result = null;
}
function normalizeFixedReduction(value: unknown): CooldownReduction | null {
@@ -435,7 +440,6 @@
function updateFixedReduction(slot: number, reduction: CooldownReduction | null): void {
fixedCooldownReductions = fixedCooldownReductions.map((value, index) => index === slot ? reduction : value);
result = null;
}
function normalizeGenerationBonusOverride(value: unknown): number | null {
@@ -455,14 +459,12 @@
function toggleAllBlueNodes(slot: number): void {
allBlueNodes = allBlueNodes.map((enabled, index) => index === slot ? !enabled : enabled);
result = null;
}
function updateGenerationBonus(slot: number, rawValue: string): void {
const parsed = Number(rawValue);
const value = rawValue.trim() === '' || !Number.isFinite(parsed) ? null : normalizeGenerationBonusOverride(parsed);
generationBonusOverrides = generationBonusOverrides.map((bonus, index) => index === slot ? value : bonus);
result = null;
}
function normalizeSongScoreBonusOverride(value: unknown): number | null {
@@ -475,7 +477,6 @@
if (input.value.trim() === '' || !Number.isFinite(parsed) || parsed < 0) input.value = '';
if (parsed > 10) input.value = '10';
songScoreBonusOverride = input.value === '' ? null : Math.min(10, parsed);
result = null;
}
function boardsForSelectedCards(
@@ -497,19 +498,16 @@
function updateCard(slot: number, cardId: string): void {
selectedCardIds = selectedCardIds.map((id, index) => (index === slot ? cardId : id));
if (collection[cardId]?.owned) updateAwakening(slot, normalizeAwakening(collection[cardId]?.awakening));
result = null;
}
function updateLeader(characterId: string): void {
leaderCharacterId = characterId;
const outfit = fiveStarCards.find((card) => card.characterId === characterId && card.details.costume);
leaderOutfitCardId = outfit?.id ?? '';
result = null;
}
function updateOutfit(cardId: string): void {
leaderOutfitCardId = cardId;
result = null;
}
function optimizerCardWithBoard(card: CardRow, index: number, unitMembers: readonly CardRow[]) {
@@ -620,7 +618,7 @@
}
async function optimizeConnectionPlacements(): Promise<void> {
if (!result || !selectedChart || selectedSourceCards.length !== 5) return;
if (resultStale || !result || !selectedChart || selectedSourceCards.length !== 5) return;
const analysisResult = result;
const chartSummary = selectedChart;
connectionOptimizationRunning = true;
@@ -738,7 +736,7 @@
}
async function applyConnectionOptimization(): Promise<void> {
if (!connectionOptimizationProposal) return;
if (resultStale || !connectionOptimizationProposal) return;
connectionOptimizationSnapshot = Object.fromEntries(Object.entries(boardCards).map(([memberId, placements]) => [memberId, { ...placements }]));
saveBoardCardPlacements(connectionOptimizationProposal.placements);
connectionOptimizationProposal = null;
@@ -754,12 +752,11 @@
}
async function analyze(): Promise<void> {
if (running) return;
await tick();
if (running) return;
errorMessage = '';
clearConnectionOptimizationProposal();
result = null;
resultCapture = null;
achievementProbabilities = undefined;
if (!worker || !selectedSong || !selectedChart) return;
if (new Set(selectedCardIds).size !== 5) {
errorMessage = '서로 다른 카드 5장을 선택해 주세요.';
@@ -780,6 +777,7 @@
});
if (!Number.isFinite(overallPower) || overallPower <= 0) throw new Error('선택한 카드의 종합력을 계산하지 못했습니다.');
running = true;
pendingInputKey = currentAnalysisKey;
pendingCapture = structuredClone({
settings: presetSnapshot,
growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
@@ -800,7 +798,7 @@
}))
});
const chart = await loadExactSongChart(selectedChart.path);
simulationContext = { cards, chart, options: { perfectNoteScore: noteScore, directSongScoreBonusPercent: songBonus } };
pendingSimulationContext = { cards, chart, options: { perfectNoteScore: noteScore, directSongScoreBonusPercent: songBonus } };
progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) };
worker.postMessage({
type: 'optimize-exact',
@@ -957,9 +955,9 @@
<div class="song-fields">
{#if mode === 'analysis'}
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} /></div>
<label>난이도
<select bind:value={difficulty} on:change={() => result = null}>
<select bind:value={difficulty}>
{#each difficulties as value}<option {value}>{difficultyLabel(value)}</option>{/each}
</select>
</label>
@@ -1076,6 +1074,8 @@
{#if mode === 'analysis' || result}
<section class="panel result" aria-labelledby="result-title">
<div class="section-title"><div><span>{mode === 'rating' ? '03' : '02'}</span><h2 id="result-title">분석 결과</h2></div></div>
{#if resultStale || pendingStale}<p class="stale-warning" role="status">입력 조건 또는 사용자 데이터가 변경되었습니다. {resultStale ? '아래는 이전 조건의 결과입니다. ' : ''}{pendingStale ? '진행 중인 분석은 시작 당시 조건으로 계속 계산합니다. ' : ''}최신 조건을 적용하려면 다시 분석해 주세요.</p>{/if}
{#if mode === 'analysis' && resultCapture}<p class="board-guide"><strong>{resultCapture.title} · {difficultyLabel(resultCapture.settings.difficulty)}</strong> · 분석 당시 종합력 {resultCapture.overallPower.toLocaleString('ko-KR')}</p>{/if}
{#if mode === 'rating' && result}<p class="board-guide"><strong>{selectedChart?.title} · {difficultyLabel(difficulty)}</strong> · 종합력 {overallPower.toLocaleString('ko-KR')} · 가창 보너스 10%</p>{/if}
{#if result}
<div class="order-sections">
@@ -1107,7 +1107,7 @@
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 편성 멤버의 파란 영역 연결 카드는 반드시 인근의 큰 발동률 노드를 포함합니다. 편성 밖 멤버의 보드도 현재 편성의 점수에 기여하는 효과는 최적화에 반영합니다. 이 조건 안에서 최고점을 최우선으로 비교하고 최고점이 같으면 액티브 발동률 UP 합계를 비교합니다. 두 값이 모두 같으면 기존 연결 카드 배치를 우선 유지합니다. 조건을 충족할 수 없거나 최종 검증에서 최고점이 낮아지면 기존 배치를 유지합니다.</p></div>
<div class="connection-optimizer-actions">
{#if connectionOptimizationSnapshot}<button type="button" disabled={running || connectionOptimizationRunning} on:click={undoConnectionOptimization}>직전 배치 복원</button>{/if}
<button type="button" class="primary" disabled={running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>
<button type="button" class="primary" disabled={resultStale || running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>
</div>
</div>
{#if connectionOptimizationProgress}
@@ -1133,7 +1133,7 @@
<span><small>{member?.name ?? assignment.memberId} · {connectionSocketName(assignment.memberId, assignment.socketId)}</small><strong>{previousCard ? `${previousCard.rarity}★ ${cardLabel(previousCard)}` : '비어 있음'} → {card ? `${card.rarity}★ ${cardLabel(card)}` : '배치 해제'}</strong></span>
{/each}
</div>
<button type="button" class="apply-connection" on:click={applyConnectionOptimization}>변경 배치를 적용하고 다시 분석</button>
<button type="button" class="apply-connection" disabled={resultStale || running} on:click={applyConnectionOptimization}>변경 배치를 적용하고 다시 분석</button>
{:else}
<p class="connection-no-changes">현재 연결 카드 배치가 이미 최적입니다.</p>
{/if}
@@ -1247,6 +1247,7 @@
</main>
<style>
.stale-warning { margin: 14px 0; padding: 11px 14px; border: 1px solid #665333; border-radius: 9px; color: #edcf92; background: #292319; font-size: 13px; line-height: 1.6; }
main { width: min(var(--page-max-width), calc(100% - var(--page-gutter))); margin: 0 auto; padding: var(--page-content-top) 0 80px; }
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: var(--page-header-bottom); }
h1 { margin: 0; color: #f3f5f8; font-size: var(--page-title-size); line-height: 1.15; letter-spacing: -.045em; }