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:
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createContext, runInContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import { compile } from 'svelte/compiler';
|
||||
|
||||
const page = readFileSync(new URL('../../routes/analysis/+page.svelte', import.meta.url), 'utf8');
|
||||
const script = page.match(/<script lang="ts">([\s\S]*?)<\/script>/)![1];
|
||||
const source = ts.createSourceFile('analysis.ts', script, ts.ScriptTarget.Latest, true);
|
||||
const names = new Set(['updateAwakening', 'updateFixedReduction', 'toggleAllBlueNodes', 'updateGenerationBonus',
|
||||
'updateSongScoreBonusOverride', 'updateCard', 'updateLeader', 'updateOutfit', 'applyPreset']);
|
||||
const snippets: string[] = [];
|
||||
function collect(node: ts.Node): void {
|
||||
if (ts.isFunctionDeclaration(node) && node.name && names.has(node.name.text)) snippets.push(node.getText(source));
|
||||
if (ts.isBinaryExpression(node) && node.left.getText(source) === 'worker.onmessage') snippets.push(node.getText(source) + ';');
|
||||
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'syncStorage') snippets.push(`const ${node.getText(source)};`);
|
||||
ts.forEachChild(node, collect);
|
||||
}
|
||||
collect(source);
|
||||
const code = ts.transpileModule(snippets.join('\n'), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
|
||||
test('편성·성장·프리셋을 수정해도 이전 결과와 확률을 유지한다', () => {
|
||||
const result = { theoreticalMaxScore: 123 }, capture = { title: 'old song' }, probabilities = { maximum: 0.1 };
|
||||
const context = createContext({ result, resultCapture: capture, achievementProbabilities: probabilities, worker: {},
|
||||
selectedAwakenings: [1], fixedCooldownReductions: [null], allBlueNodes: [false], generationBonusOverrides: [null],
|
||||
songScoreBonusOverride: null, selectedCardIds: ['old'], collection: {}, leaderCharacterId: 'old', leaderOutfitCardId: '',
|
||||
fiveStarCards: [{ id: 'outfit', characterId: 'new', details: { costume: {} } }],
|
||||
normalizeAwakening: Number, normalizeGenerationBonusOverride: Number, restoreAnalysisState: () => {}, errorMessage: '' });
|
||||
runInContext(code, context);
|
||||
for (const update of ['updateAwakening(0, 4)', 'updateFixedReduction(0, 12)', 'toggleAllBlueNodes(0)',
|
||||
'updateGenerationBonus(0, "900")', 'updateSongScoreBonusOverride({value: "10"})', 'updateCard(0, "new")',
|
||||
'updateLeader("new")', 'updateOutfit("outfit")', 'applyPreset({detail: {}})']) {
|
||||
runInContext(update, context);
|
||||
assert.equal(context.result, result, update);
|
||||
assert.equal(context.resultCapture, capture, update);
|
||||
assert.equal(context.achievementProbabilities, probabilities, update);
|
||||
}
|
||||
});
|
||||
|
||||
test('계산 완료는 현재 입력이 아닌 시작 당시 조건·시뮬레이션과 결과를 함께 교체한다', () => {
|
||||
const next = { theoreticalMaxScore: 456 }, capture = { title: 'requested song' }, simulation = { chart: 'requested chart' };
|
||||
const context = createContext({ worker: {}, result: { theoreticalMaxScore: 123 }, pendingCapture: capture,
|
||||
pendingInputKey: 'requested conditions', currentAnalysisKey: 'edited while running', pendingSimulationContext: simulation,
|
||||
resultCapture: null, resultInputKey: '', simulationContext: { chart: 'old chart' }, achievementProbabilities: null, running: true, next });
|
||||
runInContext(code, context);
|
||||
runInContext('worker.onmessage({data: {type: "result", result: next, achievementProbabilities: {maximum: 0.2}}})', context);
|
||||
assert.equal(context.result, next);
|
||||
assert.equal(context.resultCapture, capture);
|
||||
assert.equal(context.resultInputKey, 'requested conditions');
|
||||
assert.notEqual(context.resultInputKey, context.currentAnalysisKey);
|
||||
assert.equal(context.simulationContext, simulation);
|
||||
assert.equal(context.running, false);
|
||||
});
|
||||
|
||||
test('이전 결과 안내는 입력 변화에 반응하고 타임라인은 저장된 결과의 악곡 길이를 사용한다', () => {
|
||||
const code = compile(page, { filename: 'analysis.svelte', generate: 'client' }).js.code;
|
||||
const effects = code.split('$.legacy_pre_effect(');
|
||||
assert.ok(effects.find(part => part.includes('$.set(resultStale,') && part.includes('$.get(currentAnalysisKey)')));
|
||||
assert.ok(effects.find(part => part.includes('$.set(currentAnalysisKey,') && part.includes('$.get(selectedSongId)') && part.includes('$.get(difficulty)')));
|
||||
assert.ok(effects.find(part => part.includes('$.set(timelineDuration,') && part.includes('$.get(resultCapture)?.durationSeconds')));
|
||||
});
|
||||
|
||||
test('다른 탭의 성장 변경과 재분석 오류가 기존 결과·기록·시뮬레이션을 지우지 않는다', () => {
|
||||
const result = { theoreticalMaxScore: 123 }, capture = { title: 'old' }, simulation = { chart: 'old' };
|
||||
const storage = { getItem: () => '{"memoryCount":30}' };
|
||||
let loaded = '';
|
||||
const context = createContext({ worker: {}, result, resultCapture: capture, simulationContext: simulation,
|
||||
localStorage: storage, USER_DATA_STORAGE_KEY: 'growth', applyStoredUserData: (raw: string) => { loaded = raw; },
|
||||
running: true, errorMessage: '' });
|
||||
runInContext(code, context);
|
||||
runInContext('syncStorage({key: "growth", storageArea: localStorage, newValue: "stale event"})', context);
|
||||
assert.equal(loaded, '{"memoryCount":30}');
|
||||
runInContext('worker.onmessage({data: {type: "error", message: "retry"}})', context);
|
||||
assert.equal(context.result, result);
|
||||
assert.equal(context.resultCapture, capture);
|
||||
assert.equal(context.simulationContext, simulation);
|
||||
assert.equal(context.running, false);
|
||||
assert.equal(context.errorMessage, 'retry');
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createContext, runInContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
// Exercise the page's actual storage handler without a browser or expensive workers.
|
||||
const page = readFileSync(new URL('../../routes/combination/+page.svelte', import.meta.url), 'utf8');
|
||||
const script = page.match(/<script lang="ts">([\s\S]*?)<\/script>/)![1];
|
||||
const source = ts.createSourceFile('combination.ts', script, ts.ScriptTarget.Latest, true);
|
||||
const snippets: string[] = [];
|
||||
function collect(node: ts.Node): void {
|
||||
if (ts.isFunctionDeclaration(node) && node.name?.text === 'loadUserData') snippets.push(node.getText(source));
|
||||
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'syncStorage') {
|
||||
snippets.push(`const ${node.getText(source)};`);
|
||||
}
|
||||
ts.forEachChild(node, collect);
|
||||
}
|
||||
collect(source);
|
||||
assert.equal(snippets.length, 2);
|
||||
const code = ts.transpileModule(snippets.join('\n'), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
|
||||
test('다른 탭의 데이터 변경은 계산 중인 스냅샷과 기존 결과를 보존하고 안내만 표시한다', () => {
|
||||
let raw = JSON.stringify({ collection: { a: { awakening: 1 } }, memberBoards: { a: ['B1'] }, memoryCount: 1 });
|
||||
const storage = { getItem: () => raw };
|
||||
const result = { theoreticalMaxScore: 123 };
|
||||
const context = createContext({ localStorage: storage, USER_DATA_STORAGE_KEY: 'growth',
|
||||
loadedUserData: undefined, userDataChanged: false, collection: {}, memberBoards: {}, boardCards: {},
|
||||
memoryCount: 0, memberPowerUpPercent: 0, result, rankedResults: [result], running: true });
|
||||
runInContext(code + '\nloadUserData();', context);
|
||||
raw = JSON.stringify({ collection: { a: { awakening: 5 } }, memberBoards: { a: ['B1', 'B2'] }, memoryCount: 30 });
|
||||
runInContext('syncStorage({key: "growth", storageArea: localStorage});', context);
|
||||
assert.equal(context.userDataChanged, true);
|
||||
assert.equal(context.collection.a.awakening, 1);
|
||||
assert.equal(context.memoryCount, 1);
|
||||
assert.equal(context.memberBoards.a.length, 1);
|
||||
assert.equal(context.result, result);
|
||||
assert.equal(context.rankedResults[0], result);
|
||||
assert.equal(context.running, true);
|
||||
|
||||
// Only the next explicit search loads the new snapshot.
|
||||
runInContext('loadUserData();', context);
|
||||
assert.equal(context.collection.a.awakening, 5);
|
||||
assert.equal(context.memoryCount, 30);
|
||||
assert.equal(context.memberBoards.a.length, 2);
|
||||
assert.equal(context.userDataChanged, false);
|
||||
|
||||
runInContext('syncStorage({key: "growth", newValue: "outdated queued event", storageArea: localStorage});', context);
|
||||
assert.equal(context.userDataChanged, false);
|
||||
raw = 'null';
|
||||
runInContext('syncStorage({key: "other", storageArea: localStorage});', context);
|
||||
assert.equal(context.userDataChanged, false);
|
||||
runInContext('syncStorage({key: "growth", storageArea: {}});', context);
|
||||
assert.equal(context.userDataChanged, false);
|
||||
runInContext('syncStorage({key: null, storageArea: localStorage});', context);
|
||||
assert.equal(context.userDataChanged, true);
|
||||
assert.equal(context.result, result);
|
||||
});
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import cardSummary from '$lib/data/cards-summary.json';
|
||||
import { leaderOutfitCards } from '$lib/engine/leader-outfits.ts';
|
||||
@@ -168,6 +168,43 @@
|
||||
let screeningProgress = 0;
|
||||
let errorMessage = '';
|
||||
let ready = false;
|
||||
let loadedUserData: string | null | undefined;
|
||||
let userDataChanged = false;
|
||||
|
||||
function loadUserData(): void {
|
||||
const raw = localStorage.getItem(USER_DATA_STORAGE_KEY);
|
||||
const stored = JSON.parse(raw ?? 'null');
|
||||
collection = stored?.collection && typeof stored.collection === 'object' ? stored.collection : {};
|
||||
memberBoards = stored?.memberBoards && typeof stored.memberBoards === 'object' ? stored.memberBoards : {};
|
||||
boardCards = stored?.boardCards && typeof stored.boardCards === 'object' ? stored.boardCards : {};
|
||||
memoryCount = Number.isFinite(stored?.memoryCount) ? stored.memoryCount : 0;
|
||||
memberPowerUpPercent = Number.isFinite(stored?.memberPowerUpPercent) ? stored.memberPowerUpPercent : 0;
|
||||
loadedUserData = raw;
|
||||
userDataChanged = false;
|
||||
}
|
||||
|
||||
function resetBaseline(): void {
|
||||
baselineWorker?.terminate();
|
||||
const nextWorker = createScoreOptimizerWorker();
|
||||
baselineWorker = nextWorker;
|
||||
baselineResult = null;
|
||||
baselineAchievementProbabilities = undefined;
|
||||
baselineRunning = false;
|
||||
baselineError = '';
|
||||
nextWorker.onmessage = (event) => {
|
||||
if (baselineWorker !== nextWorker) return;
|
||||
if (event.data.type === 'progress') baselineProgress = event.data.progress;
|
||||
if (event.data.type === 'result') {
|
||||
baselineResult = event.data.result;
|
||||
baselineAchievementProbabilities = event.data.achievementProbabilities;
|
||||
baselineRunning = false;
|
||||
}
|
||||
if (event.data.type === 'error') {
|
||||
baselineError = event.data.message;
|
||||
baselineRunning = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$: charts = releasedSongCharts(allCharts, releaseNow);
|
||||
$: currentEvent = eventMode
|
||||
@@ -259,12 +296,7 @@
|
||||
releaseNow = currentReleaseClock.now();
|
||||
}, 1_000);
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(USER_DATA_STORAGE_KEY) ?? 'null');
|
||||
collection = stored?.collection && typeof stored.collection === 'object' ? stored.collection : {};
|
||||
memberBoards = stored?.memberBoards && typeof stored.memberBoards === 'object' ? stored.memberBoards : {};
|
||||
boardCards = stored?.boardCards && typeof stored.boardCards === 'object' ? stored.boardCards : {};
|
||||
memoryCount = Number.isFinite(stored?.memoryCount) ? stored.memoryCount : 0;
|
||||
memberPowerUpPercent = Number.isFinite(stored?.memberPowerUpPercent) ? stored.memberPowerUpPercent : 0;
|
||||
loadUserData();
|
||||
} catch {
|
||||
collection = {};
|
||||
}
|
||||
@@ -296,19 +328,7 @@
|
||||
searchPhase = 'idle';
|
||||
}
|
||||
};
|
||||
baselineWorker = createScoreOptimizerWorker();
|
||||
baselineWorker.onmessage = (event) => {
|
||||
if (event.data.type === 'progress') baselineProgress = event.data.progress;
|
||||
if (event.data.type === 'result') {
|
||||
baselineResult = event.data.result;
|
||||
baselineAchievementProbabilities = event.data.achievementProbabilities;
|
||||
baselineRunning = false;
|
||||
}
|
||||
if (event.data.type === 'error') {
|
||||
baselineError = event.data.message;
|
||||
baselineRunning = false;
|
||||
}
|
||||
};
|
||||
resetBaseline();
|
||||
try {
|
||||
baselineState = readAnalysisState(localStorage.getItem(ANALYSIS_STORAGE_KEY));
|
||||
} catch {
|
||||
@@ -316,8 +336,16 @@
|
||||
baselineError = '배치 분석의 저장 데이터를 읽지 못했습니다.';
|
||||
}
|
||||
ready = true;
|
||||
const syncStorage = (event: StorageEvent) => {
|
||||
if (event.storageArea !== localStorage || (event.key !== USER_DATA_STORAGE_KEY && event.key !== null)) return;
|
||||
// Keep the calculation's growth snapshot and all existing results intact.
|
||||
// Read the current value because queued storage events may already be outdated.
|
||||
userDataChanged = localStorage.getItem(USER_DATA_STORAGE_KEY) !== loadedUserData;
|
||||
};
|
||||
window.addEventListener('storage', syncStorage);
|
||||
return () => {
|
||||
mounted = false;
|
||||
window.removeEventListener('storage', syncStorage);
|
||||
window.clearInterval(releaseClock);
|
||||
worker?.terminate();
|
||||
terminateParallelWorkers();
|
||||
@@ -821,6 +849,7 @@
|
||||
}
|
||||
|
||||
async function analyzeBaseline(): Promise<void> {
|
||||
const targetWorker = baselineWorker;
|
||||
const state = baselineState;
|
||||
const chartMetadata = baselineChartMetadata;
|
||||
if (!state || !chartMetadata || !baselineWorker || !baselineMatchesConditions) return;
|
||||
@@ -872,6 +901,7 @@
|
||||
};
|
||||
});
|
||||
const chart = await loadExactSongChart(chartMetadata.path);
|
||||
if (baselineWorker !== targetWorker) return;
|
||||
baselineOverallPower = power.overallPower;
|
||||
baselineProgress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(optimizerCards) };
|
||||
baselineWorker.postMessage({
|
||||
@@ -883,6 +913,7 @@
|
||||
includeAchievementProbabilities: true
|
||||
});
|
||||
} catch (error) {
|
||||
if (baselineWorker !== targetWorker) return;
|
||||
baselineError = error instanceof Error ? error.message : '기준 조합을 분석하지 못했습니다.';
|
||||
baselineRunning = false;
|
||||
}
|
||||
@@ -1007,7 +1038,18 @@
|
||||
}
|
||||
|
||||
async function search(): Promise<void> {
|
||||
if (running) return;
|
||||
errorMessage = '';
|
||||
try {
|
||||
const previousUserData = loadedUserData;
|
||||
loadUserData();
|
||||
if (previousUserData !== loadedUserData) resetBaseline();
|
||||
// Rebuild reactive connection cards and candidate ownership before searching.
|
||||
await tick();
|
||||
} catch {
|
||||
errorMessage = '사용자 데이터를 읽지 못했습니다. 사용자 데이터 페이지에서 저장 상태를 확인해 주세요.';
|
||||
return;
|
||||
}
|
||||
result = null;
|
||||
if (!worker || !selectedChart) return;
|
||||
if (ownedCardsOnly && new Set(candidateCards.map((card) => card.characterId)).size < 5) {
|
||||
@@ -1259,6 +1301,7 @@
|
||||
|
||||
<section class="panel" aria-labelledby="result-title">
|
||||
<div class="section-title"><div><span>02</span><h2 id="result-title">탐색 결과</h2></div>{#if result}<strong>{screenedCombinationCount.toLocaleString('ko-KR')}개 선별 · {exactCombinationCount}개 정밀 분석 · {comparableToBaseline ? '기준 조합 포함 ' : ''}상위 {displayedRankings.length}개</strong>{/if}</div>
|
||||
{#if userDataChanged}<p class="baseline-guide" role="status">사용자 데이터가 변경되었습니다. 진행 중인 탐색과 아래 결과는 이전 데이터 기준이며, 다시 탐색하면 최신 데이터를 적용합니다.</p>{/if}
|
||||
{#if displayedRankings.length > 0}
|
||||
<div class="ranking-list">
|
||||
{#each displayedRankings as ranking, rank}
|
||||
|
||||
Reference in New Issue
Block a user