fix: restore fast connection matching with yellow bonus correction
Publish GitHub Pages output / publish (push) Successful in 32s
Publish GitHub Pages output / publish (push) Successful in 32s
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { refineBoardConnectionSongBonus } from '$lib/engine/board-connection-song-refinement';
|
||||
import { ANALYSIS_TRANSFER_KEY, parseAnalysisTransfer, transferredAnalysisKey } from '$lib/models/analysis-transfer.ts';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { activeWindowKey, calculateActiveImpacts } from '$lib/engine/active-impact.ts';
|
||||
@@ -20,8 +21,8 @@
|
||||
import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte';
|
||||
import { calculateMaximumScoreProbabilityTimeline } from '$lib/engine/score-probability.ts';
|
||||
import SongPicker from '$lib/components/SongPicker.svelte';
|
||||
import { buildExactSkillWindows, calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionPlacementsExact, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
|
||||
import type { ExactScoreOptions, OptimizerCard, CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
|
||||
import { buildExactSkillWindows, calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
|
||||
import type { ExactScoreOptions, OptimizerCard, CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
|
||||
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
||||
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
|
||||
|
||||
@@ -196,10 +197,6 @@
|
||||
let errorMessage = '';
|
||||
let analysisReady = false;
|
||||
let connectionOptimizationRunning = false;
|
||||
let connectionMaximizeCompute = false;
|
||||
let connectionReportedCores = 1;
|
||||
let connectionOptimizationController: AbortController | null = null;
|
||||
let connectionOptimizationInputKey = '';
|
||||
let connectionOptimizationProgress: { stage: string; completed: number; total: number } | null = null;
|
||||
let connectionOptimizationError = '';
|
||||
let connectionOptimizationProposal: ConnectionOptimizationProposal | null = null;
|
||||
@@ -292,9 +289,6 @@
|
||||
}
|
||||
$: currentAnalysisKey = JSON.stringify({ songId: selectedSongId, difficulty, input: ratingInputKey });
|
||||
$: resultStale = mode === 'analysis' && Boolean(result) && resultInputKey !== currentAnalysisKey;
|
||||
$: if (connectionOptimizationRunning && connectionOptimizationInputKey !== currentAnalysisKey) {
|
||||
connectionOptimizationController?.abort(new Error('설정이 변경되어 탐색을 중단했습니다. 다시 분석한 뒤 최적화해 주세요.'));
|
||||
}
|
||||
$: pendingStale = mode === 'analysis' && running && pendingInputKey !== currentAnalysisKey;
|
||||
$: if (analysisReady) saveAnalysisState({
|
||||
schemaVersion: 2,
|
||||
@@ -311,8 +305,6 @@
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const cores = navigator.hardwareConcurrency || 4;
|
||||
connectionReportedCores = Number.isFinite(cores) ? Math.max(1, Math.floor(cores)) : 1;
|
||||
let mounted = true;
|
||||
void synchronizeReleaseClock().then((clock) => {
|
||||
if (!mounted) return;
|
||||
@@ -427,7 +419,6 @@
|
||||
window.clearInterval(releaseClock);
|
||||
window.removeEventListener('storage', syncStorage);
|
||||
worker?.terminate();
|
||||
connectionOptimizationController?.abort(new Error('페이지를 떠나 탐색을 중단했습니다.'));
|
||||
for (const comparisonWorker of comparisonWorkers) comparisonWorker.terminate();
|
||||
};
|
||||
});
|
||||
@@ -671,80 +662,100 @@
|
||||
}
|
||||
|
||||
async function optimizeConnectionPlacements(): Promise<void> {
|
||||
if (connectionOptimizationRunning || resultStale || !result || !selectedChart || selectedSourceCards.length !== 5) return;
|
||||
if (resultStale || !result || !selectedChart || selectedSourceCards.length !== 5) return;
|
||||
const analysisResult = result;
|
||||
const chartSummary = selectedChart;
|
||||
const controller = new AbortController();
|
||||
connectionOptimizationController = controller;
|
||||
connectionOptimizationInputKey = currentAnalysisKey;
|
||||
connectionOptimizationRunning = true;
|
||||
connectionOptimizationProgress = { stage: '채보 준비 중', completed: 0, total: 0 };
|
||||
connectionOptimizationError = '';
|
||||
connectionOptimizationProposal = null;
|
||||
const connectionWorkers = new Map<number, Worker>();
|
||||
const concurrency = connectionMaximizeCompute ? connectionReportedCores : 1;
|
||||
try {
|
||||
const chart = await loadExactSongChart(chartSummary.path);
|
||||
controller.signal.throwIfAborted();
|
||||
const beforeContext = connectionScoreContext(boardCards, chartSummary);
|
||||
const emptyContext = connectionScoreContext({}, chartSummary);
|
||||
const orderedCards = (context: ReturnType<typeof connectionScoreContext>) => analysisResult.order.map((cardId) =>
|
||||
context.cards.find((card) => card.id === cardId)!
|
||||
);
|
||||
const referenceScore = (context: ReturnType<typeof connectionScoreContext>) => calculateExactScore(
|
||||
orderedCards(context), analysisResult.cooldownReductions, chart, {
|
||||
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
|
||||
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent
|
||||
}
|
||||
).theoreticalMaxScore;
|
||||
const emptyScore = referenceScore(emptyContext);
|
||||
const emptyActivationRateUp = totalActivationRateUp(emptyContext);
|
||||
const beforeActivationRateUp = totalActivationRateUp(beforeContext);
|
||||
const currentPlacements = structuredClone(boardCards);
|
||||
const activeBoards = structuredClone(effectiveMemberBoards);
|
||||
const candidateCards = cardSummary.cards
|
||||
.filter((card) => card.details.connect.length > 0 && collection[card.id]?.owned)
|
||||
.map((card) => ({ id: card.id, awakening: collection[card.id]?.awakening ?? 0, connect: card.details.connect }));
|
||||
if (candidateCards.length === 0) throw new Error('최적화에 사용할 보유 연결 카드가 없습니다.');
|
||||
const evaluateContext = (context: ReturnType<typeof connectionScoreContext>, workerIndex = 0) => new Promise<ExactOptimizationResult>((resolve, reject) => {
|
||||
controller.signal.throwIfAborted();
|
||||
const evaluatorWorker = connectionWorkers.get(workerIndex) ?? createScoreOptimizerWorker();
|
||||
connectionWorkers.set(workerIndex, evaluatorWorker);
|
||||
comparisonWorkers.add(evaluatorWorker);
|
||||
const abort = () => { evaluatorWorker.terminate(); reject(controller.signal.reason); };
|
||||
controller.signal.throwIfAborted();
|
||||
controller.signal.addEventListener('abort', abort, { once: true });
|
||||
const finish = () => controller.signal.removeEventListener('abort', abort);
|
||||
evaluatorWorker.onerror = () => { finish(); reject(new Error('배치별 점수 최적화 중 오류가 발생했습니다.')); };
|
||||
evaluatorWorker.onmessage = (event) => {
|
||||
if (event.data.type === 'result') { finish(); resolve(event.data.result); }
|
||||
if (event.data.type === 'error') { finish(); reject(new Error(event.data.message)); }
|
||||
const placementMetricCache = new Map<string, { score: number; activationRateUp: number }>();
|
||||
const placementMetrics = (card: OptimizableConnectionCard, memberId: string, socketId: string) => {
|
||||
const effect = card.connect.find((entry) => entry.level === (card.awakening >= 5 ? 2 : 1)) ?? card.connect.at(-1);
|
||||
const effectKey = effect ? `${effect.effectPermilUp}:${effect.extent.map((offset) => `${offset.x},${offset.y}`).join(';')}` : card.id;
|
||||
const key = `${memberId}:${socketId}:${effectKey}`;
|
||||
const cached = placementMetricCache.get(key);
|
||||
if (cached) return cached;
|
||||
const context = connectionScoreContext({ [memberId]: { [socketId]: card.id } }, chartSummary);
|
||||
const metrics = {
|
||||
score: referenceScore(context) - emptyScore,
|
||||
activationRateUp: totalActivationRateUp(context) - emptyActivationRateUp
|
||||
};
|
||||
evaluatorWorker.postMessage({ type: 'optimize-exact', cards: context.cards, chart,
|
||||
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
|
||||
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent });
|
||||
placementMetricCache.set(key, metrics);
|
||||
return metrics;
|
||||
};
|
||||
const activeConnectionSlots = boardMembers.flatMap((member) => {
|
||||
const positions = (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId] ?? [];
|
||||
const active = new Set(effectiveMemberBoards[member.id] ?? []);
|
||||
const center = positions.find((position) => position.x === 0 && position.y === 0);
|
||||
if (center) active.add(center.groupId);
|
||||
return positions.filter((position) => active.has(position.groupId) && position.groupId.startsWith('S'))
|
||||
.map((position) => ({ memberId: member.id, socketId: position.groupId }));
|
||||
});
|
||||
const evaluationCache = new Map<string, Promise<{ score: number; activationRateUp: number }>>();
|
||||
connectionOptimizationProgress = { stage: '전역 최적 배치 탐색 중 · 완료 전에는 적용할 수 없습니다', completed: 0, total: 0 };
|
||||
const exactPlacements = await optimizeBoardConnectionPlacementsExact(boardSummary, activeBoards, candidateCards, currentPlacements, {
|
||||
currentPlacements,
|
||||
const totalPlacementChecks = candidateCards.length * activeConnectionSlots.length;
|
||||
connectionOptimizationProgress = { stage: '카드별 배치 효과 계산 중', completed: 0, total: totalPlacementChecks };
|
||||
let completedPlacementChecks = 0;
|
||||
for (const card of candidateCards) {
|
||||
for (const slot of activeConnectionSlots) {
|
||||
placementMetrics(card, slot.memberId, slot.socketId);
|
||||
completedPlacementChecks += 1;
|
||||
if (completedPlacementChecks % 8 === 0 || completedPlacementChecks === totalPlacementChecks) {
|
||||
connectionOptimizationProgress = { stage: '카드별 배치 효과 계산 중', completed: completedPlacementChecks, total: totalPlacementChecks };
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
}
|
||||
}
|
||||
connectionOptimizationProgress = { stage: '추천 배치 선택 중', completed: totalPlacementChecks, total: totalPlacementChecks };
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, {
|
||||
currentPlacements: boardCards,
|
||||
requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId),
|
||||
signal: controller.signal,
|
||||
concurrency,
|
||||
groupEquivalentEffects: true,
|
||||
evaluate: async (placements, workerIndex) => {
|
||||
controller.signal.throwIfAborted();
|
||||
preservePlacementOnTie: () => true,
|
||||
placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score,
|
||||
placementTieWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).activationRateUp
|
||||
});
|
||||
if (optimized.assignments.length === 0) throw new Error('현재 활성 노드에 효과를 주는 연결 카드 배치를 찾지 못했습니다.');
|
||||
optimized.placements = await refineBoardConnectionSongBonus(boardSummary, effectiveMemberBoards, candidateCards, optimized.placements, {
|
||||
currentPlacements: boardCards,
|
||||
requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId),
|
||||
song: chartSummary,
|
||||
songBonusEnabled: songScoreBonusOverride === null,
|
||||
evaluate: (placements) => {
|
||||
const context = connectionScoreContext(placements, chartSummary);
|
||||
const key = JSON.stringify([context.cards,
|
||||
perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
|
||||
context.boardEffects.directSongScoreBonusPercent, totalActivationRateUp(context)]);
|
||||
const cached = evaluationCache.get(key);
|
||||
if (cached) return cached;
|
||||
const pending = evaluateContext(context, workerIndex).then((evaluated) => ({
|
||||
score: evaluated.theoreticalMaxScore, activationRateUp: totalActivationRateUp(context)
|
||||
}));
|
||||
if (evaluationCache.size >= 2048) evaluationCache.clear();
|
||||
evaluationCache.set(key, pending);
|
||||
return pending;
|
||||
return { score: referenceScore(context), activationRateUp: totalActivationRateUp(context) };
|
||||
},
|
||||
onProgress: async (checks) => {
|
||||
connectionOptimizationProgress = { stage: '전역 최적 배치 탐색 중 · ' + checks.toLocaleString('ko-KR') + '개 확인 · 최대 ' + concurrency + '개 동시 계산', completed: 0, total: 0 };
|
||||
connectionOptimizationProgress = { stage: '노란 보너스 상한 보정 · ' + checks.toLocaleString('ko-KR') + '회 비교', completed: 0, total: 0 };
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
});
|
||||
controller.signal.throwIfAborted();
|
||||
connectionOptimizationProgress = { stage: '전역 탐색 완료 · 최종 결과 준비 중', completed: 0, total: 0 };
|
||||
const afterContext = connectionScoreContext(exactPlacements, chartSummary);
|
||||
const after = await evaluateContext(afterContext);
|
||||
controller.signal.throwIfAborted();
|
||||
connectionOptimizationProgress = { stage: '최종 점수 검증 중', completed: totalPlacementChecks, total: totalPlacementChecks };
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const afterContext = connectionScoreContext(optimized.placements, chartSummary);
|
||||
const after = optimizeExactOrderAndCooldowns(afterContext.cards, chart, {
|
||||
perfectNoteScore: perfectNoteScoreFromPower(afterContext.unitPower.overallPower, chartSummary.scoreRatio),
|
||||
directSongScoreBonusPercent: afterContext.boardEffects.directSongScoreBonusPercent
|
||||
});
|
||||
const afterActivationRateUp = totalActivationRateUp(afterContext);
|
||||
if (after.theoreticalMaxScore < analysisResult.theoreticalMaxScore) {
|
||||
throw new Error(`현재 배치의 최고점(${analysisResult.theoreticalMaxScore.toLocaleString('ko-KR')})이 탐색 결과보다 높아 기존 배치를 유지합니다.`);
|
||||
@@ -754,7 +765,7 @@
|
||||
}
|
||||
const changedAssignments = boardMembers.flatMap((member) => {
|
||||
const before = boardCards[member.id] ?? {};
|
||||
const afterPlacements = exactPlacements[member.id] ?? {};
|
||||
const afterPlacements = optimized.placements[member.id] ?? {};
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(afterPlacements)])]
|
||||
.filter((socketId) => before[socketId] !== afterPlacements[socketId])
|
||||
.map((socketId) => ({
|
||||
@@ -765,7 +776,7 @@
|
||||
}));
|
||||
});
|
||||
connectionOptimizationProposal = {
|
||||
placements: exactPlacements,
|
||||
placements: optimized.placements,
|
||||
assignments: changedAssignments,
|
||||
beforeExpected: analysisResult.expectedScore,
|
||||
afterExpected: after.expectedScore,
|
||||
@@ -777,9 +788,6 @@
|
||||
} catch (error) {
|
||||
connectionOptimizationError = error instanceof Error ? error.message : '연결 카드 최적화를 완료하지 못했습니다.';
|
||||
} finally {
|
||||
controller.abort(new Error('연결 카드 탐색을 종료했습니다.'));
|
||||
for (const connectionWorker of connectionWorkers.values()) { connectionWorker.terminate(); comparisonWorkers.delete(connectionWorker); }
|
||||
connectionOptimizationController = null;
|
||||
connectionOptimizationRunning = false;
|
||||
connectionOptimizationProgress = null;
|
||||
}
|
||||
@@ -1155,19 +1163,12 @@
|
||||
|
||||
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
|
||||
<div class="connection-optimizer-heading">
|
||||
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 편성 멤버의 파란 영역 연결 카드는 반드시 인근의 큰 발동률 노드를 포함합니다. 편성 밖 멤버의 보드도 현재 편성의 점수에 기여하는 효과는 최적화에 반영합니다. 이 조건 안에서 최고점을 최우선으로 비교하고 최고점이 같으면 액티브 발동률 UP 합계를 비교합니다. 두 값이 모두 같으면 기존 연결 카드 배치를 우선 유지합니다. 조건을 충족할 수 없거나 최종 검증에서 최고점이 낮아지면 기존 배치를 유지합니다.</p></div>
|
||||
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 편성 멤버의 파란 영역 연결 카드는 반드시 인근의 큰 발동률 노드를 포함합니다. 편성 밖 멤버의 보드도 현재 편성의 점수에 기여하는 효과는 최적화에 반영합니다. 빠른 배정 후 노란 보너스 상한에 따른 이동·교환을 최대 3차례 보정하며, 전역 최적해를 보장하지 않습니다. 이 조건 안에서 최고점을 최우선으로 비교하고 최고점이 같으면 액티브 발동률 UP 합계를 비교합니다. 두 값이 모두 같으면 기존 연결 카드 배치를 우선 유지합니다. 조건을 충족할 수 없거나 최종 검증에서 최고점이 낮아지면 기존 배치를 유지합니다.</p></div>
|
||||
<div class="connection-optimizer-actions">
|
||||
{#if connectionOptimizationRunning}<button type="button" on:click={() => connectionOptimizationController?.abort(new Error('탐색을 중단했습니다. 최적해가 확정되지 않아 배치를 변경하지 않았습니다.'))}>탐색 중단</button>{/if}
|
||||
{#if connectionOptimizationSnapshot}<button type="button" disabled={running || connectionOptimizationRunning} on:click={undoConnectionOptimization}>직전 배치 복원</button>{/if}
|
||||
<button type="button" class="primary" disabled={resultStale || running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="connection-compute-option">
|
||||
<input type="checkbox" bind:checked={connectionMaximizeCompute} disabled={connectionOptimizationRunning} aria-describedby="connection-compute-help">
|
||||
<strong>최대 컴퓨팅 자원 활용</strong>
|
||||
<span>{connectionMaximizeCompute ? `최대 ${connectionReportedCores}개 동시 계산` : '한 번에 1개 계산'}</span>
|
||||
</label>
|
||||
<p id="connection-compute-help" class="board-guide">기기의 논리 코어 수에 맞춰 연결 카드 배치를 동시에 계산합니다. 켜거나 꺼도 최적화 기준과 결과는 같습니다.</p>
|
||||
{#if connectionOptimizationProgress}
|
||||
<div class="connection-optimizer-progress" aria-live="polite">
|
||||
<div><strong>{connectionOptimizationProgress.stage}</strong>{#if connectionOptimizationProgress.total > 0}<span>{connectionOptimizationProgress.completed.toLocaleString('ko-KR')} / {connectionOptimizationProgress.total.toLocaleString('ko-KR')}</span>{/if}</div>
|
||||
@@ -1177,7 +1178,6 @@
|
||||
{#if connectionOptimizationError}<p class="connection-optimizer-error" role="alert">{connectionOptimizationError}</p>{/if}
|
||||
{#if connectionOptimizationProposal}
|
||||
<div class="connection-proposal" aria-live="polite">
|
||||
<p>현재 편성·보드·필수 범위 조건에서 전역 최적 배치 탐색을 완료했습니다.</p>
|
||||
<div class="connection-score-comparison">
|
||||
<span>예상 점수 <strong>{connectionOptimizationProposal.beforeExpected.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterExpected.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterExpected >= connectionOptimizationProposal.beforeExpected ? '+' : ''}{(connectionOptimizationProposal.afterExpected - connectionOptimizationProposal.beforeExpected).toLocaleString('ko-KR')}</b></span>
|
||||
<span>이론상 고점 <strong>{connectionOptimizationProposal.beforeMaximum.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterMaximum.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterMaximum >= connectionOptimizationProposal.beforeMaximum ? '+' : ''}{(connectionOptimizationProposal.afterMaximum - connectionOptimizationProposal.beforeMaximum).toLocaleString('ko-KR')}</b></span>
|
||||
@@ -1194,7 +1194,7 @@
|
||||
</div>
|
||||
<button type="button" class="apply-connection" disabled={resultStale || running} on:click={applyConnectionOptimization}>변경 배치를 적용하고 다시 분석</button>
|
||||
{:else}
|
||||
<p class="connection-no-changes">현재 연결 카드 배치가 이미 최적입니다.</p>
|
||||
<p class="connection-no-changes">이번 보정에서는 기존 배치보다 좋은 배치를 찾지 못했습니다.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1395,8 +1395,6 @@
|
||||
.connection-optimizer-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.connection-optimizer-heading h3 { margin: 0; color: #dce8e0; font-size: 17px; }
|
||||
.connection-optimizer-heading p { margin: 5px 0 0; color: #8fa095; font-size: 13px; }
|
||||
.connection-compute-option { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.connection-compute-option span { color: var(--muted); font-size: 0.85rem; }
|
||||
.connection-optimizer-actions { display: flex; flex: 0 0 auto; gap: 7px; }
|
||||
.connection-optimizer button { min-height: 38px; padding: 8px 12px; border: 1px solid #46574c; border-radius: 8px; color: #becbc2; background: #202a23; font-weight: 800; cursor: pointer; }
|
||||
.connection-optimizer button.primary, .connection-optimizer button.apply-connection { border-color: #69b184; color: #101913; background: #78c493; }
|
||||
|
||||
Reference in New Issue
Block a user