fix: preserve analysis results and flag stale user data
Publish GitHub Pages output / publish (push) Successful in 51s
Publish GitHub Pages output / publish (push) Successful in 51s
This commit is contained in:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user