fix: restore fast connection matching with yellow bonus correction
Publish GitHub Pages output / publish (push) Successful in 32s

This commit is contained in:
2026-09-17 20:38:18 +09:00
parent 9ba5f73500
commit ba21e8f1c1
7 changed files with 223 additions and 478 deletions
+78 -80
View File
@@ -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; }