perf: 조합 탐색 정밀 계산과 Worker 처리 최적화
Publish GitHub Pages output / publish (push) Successful in 34s

카드 조건과 빈도별 공통 계산을 재사용하고 준비·평가 Worker를 재사용합니다.
준비된 후보를 모두 정밀 평가하고 최종 결과를 한 번 표시합니다.
업데이트 문구를 한 항목으로 통합합니다.
This commit is contained in:
dal4segno committed 2026-10-13 12:03:10 +09:00
1 parent f009a7fd6b
commit da40ec4238
18 files changed
+1623 -467

No files matched your search

+174 -399
View File
@@ -21,6 +21,7 @@
additionalAwakeningsRequired,
calculateBoardEffects,
calculateUnitPower,
createBoardEffectContext,
combinationSearchLimits,
compareEventRankings,
countOptimizationCandidates,
@@ -41,6 +42,11 @@
import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard, ScoreAchievementProbabilities } 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';
import { createCombinationPreparationWorker } from '$lib/workers/create-combination-preparation-worker.ts';
import { CombinationEvaluationPool } from '$lib/engine/combination-evaluation-pool.ts';
import { CombinationWorkerSession } from '$lib/engine/combination-worker-session.ts';
import type { CombinationPreparationContext } from '$lib/engine/combination-preparation.ts';
import type { PreparationRequest } from '$lib/workers/combination-preparation.worker.ts';
export let eventMode = false;
export let eventScoreMode = false;
@@ -152,10 +158,16 @@
let resultLimit = 10;
let activeResultLimit = 10;
let maximizeCompute = false;
let evaluationPool: CombinationEvaluationPool | null = null;
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
let worker: Worker | null = null;
let parallelWorkers: Worker[] = [];
let preparationWorker: Worker | null = null;
let preparationContext: CombinationPreparationContext | null = null;
let rejectPreparation: ((error: Error) => void) | null = null;
const searchWorkers = new CombinationWorkerSession<Worker>();
let activeSearchId = 0;
let searchNotice = '';
let baselineWorker: Worker | null = null;
let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
@@ -368,28 +380,6 @@
} catch {
localStorage.removeItem(STORAGE_KEY);
}
worker = createScoreOptimizerWorker();
worker.onmessage = (event) => {
if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'combination-result') {
const rankings = Array.isArray(event.data.rankings)
? event.data.rankings as RankedResult[]
: [{ result: event.data.result, overallPower: event.data.overallPower }];
completeExactPass(rankings);
}
if (event.data.type === 'achievement-progress') progress = event.data.progress;
if (event.data.type === 'achievement-result') {
rankedResults = event.data.rankings as RankedResult[];
result = rankedResults[0]?.result ?? null;
running = false;
searchPhase = 'idle';
}
if (event.data.type === 'error') {
errorMessage = event.data.message;
running = false;
searchPhase = 'idle';
}
};
resetBaseline();
try {
baselineState = readAnalysisState(localStorage.getItem(ANALYSIS_STORAGE_KEY));
@@ -409,8 +399,7 @@
mounted = false;
window.removeEventListener('storage', syncStorage);
window.clearInterval(releaseClock);
worker?.terminate();
terminateParallelWorkers();
clearSearchWorkers();
baselineWorker?.terminate();
};
});
@@ -621,58 +610,6 @@
result = null;
}
function roughCardScore(card: CardRow): number {
const statTotal = Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0);
const { activeValue, specialValue } = roughSkillValues(card);
const score = statTotal * (1 + activeValue / 100 + specialValue / 500);
if (!currentEvent || !eventPtMode) return score;
const bonus = eventBonusBreakdown(currentEvent, [card], (candidate) => awakeningForCard(candidate as CardRow));
return score * (1 + bonus.totalPercent / 100);
}
function roughStatScore(card: CardRow): number {
return Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0);
}
function roughSkillScore(card: CardRow): number {
const { adapted, activeValue, specialValue } = roughSkillValues(card);
return activeValue + specialValue + (adapted.scoreSupportPercent ?? 0);
}
function roughSkillValues(card: CardRow) {
const adapted = toOptimizerCard(card, awakeningForCard(card), [0], [card]);
const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds /
Math.max(1, adapted.active.cooldownSeconds) * effectiveActiveProbability(adapted);
const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100;
return { adapted, activeValue, specialValue };
}
function roughCombinationScore(cards: readonly CardRow[]): number {
const statTotal = cards.reduce((sum, card) => sum + roughStatScore(card), 0);
const skillPotential = cards.reduce((sum, card) => {
const adapted = toOptimizerCard(card, awakeningForCard(card), [0], cards);
const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds /
Math.max(1, adapted.active.cooldownSeconds) * effectiveActiveProbability(adapted);
const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100;
const activationValue = (adapted.special.activationRateUpPercent ?? 0) * adapted.special.durationSeconds / 100;
const passiveValue = (adapted.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) =>
effectSum + effect.percent * (effect.target === 'all' ? cards.length : effect.targetCount ?? 1), 0);
return sum + activeValue + specialValue + activationValue + passiveValue;
}, 0);
const score = statTotal * (1 + skillPotential / 500);
if (!currentEvent) return score;
const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow));
const songBonus = eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id));
return score * (eventPtMode ? 1 + bonus.totalPercent / 100 : 1) * (1 + songBonus / 100);
}
function roughEventBonusPriority(cards: readonly CardRow[]): number {
if (!currentEvent || !eventPtMode) return roughCombinationScore(cards);
const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow));
// 최대 보너스 편성이 순수 스코어 후보에 밀려 선별 단계에서 사라지지 않도록 별도 층을 보존한다.
return bonus.totalPercent * 1_000_000_000_000 + roughCombinationScore(cards);
}
function rankingValue(ranking: RankedResult): number {
return eventPtMode
? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0)
@@ -690,230 +627,6 @@
return cards.map((card) => card.id).sort().join('|');
}
function boardsForCombination(cards: readonly CardRow[]): MemberBoards {
const boards = Object.fromEntries(Object.entries(memberBoards).map(([characterId, groupIds]) => [characterId, Array.from(groupIds)]));
for (const card of cards) {
const slot = fixedSlot(card.id);
if (slot >= 0 && !fixedAllBlueNodes[slot]) continue;
const existingNonBlue = (boards[card.characterId] ?? []).filter((groupId) => !groupId.startsWith('B'));
boards[card.characterId] = [...existingNonBlue, ...(blueGroupsByCharacter[card.characterId] ?? [])];
}
return boards;
}
function acceptsCombination(cards: readonly CardRow[]): boolean {
return withinMediumProbabilityCardLimit(cards, maxMediumProbabilityCards);
}
function screeningCombinations(): CardRow[][] {
const limits = combinationSearchLimits(fixedCards.length);
const screeningLimit = eventMode ? Math.min(limits.screeningLimit, 9_000) : limits.screeningLimit;
if (fixedCards.length === 4) {
return generateCombinationCandidates(fixedCards, candidateCards, 5, roughCardScore, screeningLimit, undefined, acceptsCombination);
}
const globalBudget = eventMode ? screeningLimit : Math.ceil(screeningLimit * 0.8);
const anchoredBudget = screeningLimit - globalBudget;
const strategies = eventMode
? [
{ scoreCard: roughCardScore },
{ scoreCard: roughStatScore, scoreCombination: roughCombinationScore },
{ scoreCard: roughCardScore, scoreCombination: roughEventBonusPriority }
]
: [
{ scoreCard: roughCardScore },
{ scoreCard: roughSkillScore },
{ scoreCard: roughStatScore },
{ scoreCard: roughStatScore, scoreCombination: roughCombinationScore }
];
const perStrategy = Math.ceil(globalBudget / strategies.length);
const combined = new Map<string, CardRow[]>();
for (const strategy of strategies) {
for (const cards of generateCombinationCandidates(
fixedCards,
candidateCards,
5,
strategy.scoreCard,
perStrategy,
strategy.scoreCombination,
acceptsCombination
)) {
combined.set(combinationKey(cards), cards);
}
}
if (eventMode) return Array.from(combined.values()).slice(0, screeningLimit);
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
const anchorCount = candidateCards.filter((card) => !fixedCharacters.has(card.characterId)).length;
const perAnchorLimit = Math.max(1, Math.ceil(anchoredBudget / Math.max(1, anchorCount)));
for (const cards of generateAnchoredCombinationCandidates(
fixedCards,
candidateCards,
5,
strategies,
perAnchorLimit,
acceptsCombination
)) {
combined.set(combinationKey(cards), cards);
}
return Array.from(combined.values()).slice(0, screeningLimit);
}
function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[] {
if (fixedCards.length === 4) return prepared;
const { baseExactFinalistLimit } = combinationSearchLimits(fixedCards.length);
const finalistLimit = eventMode ? Math.min(baseExactFinalistLimit, 96) : baseExactFinalistLimit;
const selected = new Map<string, PreparedCandidate>();
const add = (candidates: PreparedCandidate[], count: number) => {
for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate);
};
const diverse = selectMemberDiverseCandidates(
prepared,
(candidate) => candidate.cards,
(candidate) => candidate.heuristic,
fixedCardIds,
Math.ceil(finalistLimit / 2)
);
add(diverse, diverse.length);
const remainingLimit = Math.max(0, finalistLimit - selected.size);
const heuristicLimit = Math.ceil(remainingLimit / 2);
const skillLimit = Math.ceil(remainingLimit / 4);
const powerLimit = remainingLimit - heuristicLimit - skillLimit;
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), heuristicLimit);
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), skillLimit);
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), powerLimit);
if (selected.size < finalistLimit) {
for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) {
selected.set(combinationKey(candidate.cards), candidate);
if (selected.size >= finalistLimit) break;
}
}
return Array.from(selected.values()).slice(0, finalistLimit);
}
function buildRefinementFinalists(rankings: RankedResult[]): PreparedCandidate[] {
if (fixedCards.length === 4 || rankings.length === 0) return [];
const { alternativeExactFinalistLimit } = combinationSearchLimits(fixedCards.length);
const alternatives = new Map<string, { cards: CardRow[]; roughScore: number }>();
const topResultNeighbors = new Map<string, { cards: CardRow[]; roughScore: number }>();
const secondaryPriority = new Map<string, { cards: CardRow[]; roughScore: number }>();
const fixedIds = new Set(fixedCards.map((card) => card.id));
for (const [rankingIndex, ranking] of rankings.slice(0, 10).entries()) {
const sourceCards = ranking.result.order.map((cardId) => cardById.get(cardId)).filter((card): card is CardRow => Boolean(card));
if (sourceCards.length !== 5) continue;
const sourceKey = combinationKey(sourceCards);
const bestByReplacementId = new Map<string, { cards: CardRow[]; roughScore: number }>();
sourceCards.forEach((source, index) => {
if (fixedIds.has(source.id)) return;
const otherCharacters = new Set(sourceCards.filter((_, cardIndex) => cardIndex !== index).map((card) => card.characterId));
for (const replacement of candidateCards) {
if (replacement.id === source.id || otherCharacters.has(replacement.characterId)) continue;
const cards = sourceCards.map((card, cardIndex) => cardIndex === index ? replacement : card);
if (!acceptsCombination(cards)) continue;
const key = combinationKey(cards);
if (key === sourceKey) continue;
const candidate = alternatives.get(key) ?? { cards, roughScore: roughCombinationScore(cards) };
alternatives.set(key, candidate);
if (rankingIndex === 0) topResultNeighbors.set(key, candidate);
const previous = bestByReplacementId.get(replacement.id);
if (!previous || candidate.roughScore > previous.roughScore) bestByReplacementId.set(replacement.id, candidate);
}
});
if (rankingIndex > 0 && rankingIndex < 3) {
for (const candidate of bestByReplacementId.values()) secondaryPriority.set(combinationKey(candidate.cards), candidate);
}
}
const refinementLimit = eventMode
? Math.min(alternativeExactFinalistLimit, 48)
: Math.max(alternativeExactFinalistLimit, topResultNeighbors.size);
const rankedAlternatives = Array.from(alternatives.values()).sort((left, right) =>
right.roughScore - left.roughScore || combinationKey(left.cards).localeCompare(combinationKey(right.cards)));
const chosenAlternatives = new Map<string, { cards: CardRow[]; roughScore: number }>();
const rankedTopNeighbors = [...topResultNeighbors.values()].sort((left, right) => right.roughScore - left.roughScore);
for (const candidate of rankedTopNeighbors) {
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
if (chosenAlternatives.size >= refinementLimit) break;
}
for (const candidate of secondaryPriority.values()) {
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
if (chosenAlternatives.size >= refinementLimit) break;
}
for (const candidate of rankedAlternatives) {
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
if (chosenAlternatives.size >= refinementLimit) break;
}
return Array.from(chosenAlternatives.values()).map((candidate) => prepareCandidate(candidate.cards));
}
function prepareCandidate(cards: CardRow[]): PreparedCandidate {
const generationBonusByCardId = Object.fromEntries(cards
.filter((card) => {
const slot = fixedSlot(card.id);
return slot < 0 ? candidateGenerationBonus !== null : fixedGenerationBonusOverrides[slot] !== null;
})
.map((card) => {
const slot = fixedSlot(card.id);
return [card.id, slot < 0 ? candidateGenerationBonus ?? 0 : fixedGenerationBonusOverrides[slot] ?? 0];
}));
const boardEffects = calculateBoardEffects(boardSummary, boardsForCombination(cards), cards, leaderCharacterId, selectedChart, {
placements: boardCards,
cards: connectionCards
}, {
generationBonusByCardId,
...(songScoreBonusOverride === null ? {} : { directSongScoreBonusPercent: songScoreBonusOverride })
});
const power = calculateUnitPower(
cards.map((card) => ({ card, awakening: awakeningForCard(card) })),
leaderOutfit,
boardEffects,
memoryParameterPermil(memoryCount, memorySummary.effects),
memberPowerUpPercent
);
const optimizerCards = cards.map((card) => {
const slot = fixedSlot(card.id);
const fixedReduction = slot >= 0 ? fixedCooldownReductions[slot] : null;
const adapted = toOptimizerCard(
card,
awakeningForCard(card),
fixedReduction === null ? boardEffects.allowedCooldownReductionsByCardId[card.id] : [fixedReduction],
cards
);
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
return {
...adapted,
active: { ...adapted.active, activationRateUpPercent: activationRate },
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
};
});
const skillPotential = optimizerCards.reduce((sum, card) => sum +
card.active.scoreUpPercent * card.active.durationSeconds / Math.max(1, card.active.cooldownSeconds) * effectiveActiveProbability(card) +
card.special.scoreUpPercent * card.special.durationSeconds / 100 +
(card.scoreSupportPercent ?? 0) +
(card.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) => effectSum + effect.percent * (effect.target === 'all' ? 5 : effect.targetCount ?? 1), 0), 0);
const eventBonus = currentEvent
? eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow))
: null;
const eventSongScoreBonus = currentEvent
? eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id))
: 0;
const directSongScoreBonusPercent = boardEffects.directSongScoreBonusPercent;
const rankingMultiplier = eventPtMode && eventBonus ? 1 + eventBonus.totalPercent / 100 : undefined;
return {
cards: optimizerCards,
overallPower: power.overallPower,
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, selectedChart?.scoreRatio ?? null),
directSongScoreBonusPercent,
finalScoreBonusPercent: eventSongScoreBonus,
heuristic: power.overallPower * (1 + skillPotential / 500) * (rankingMultiplier ?? 1) * (1 + eventSongScoreBonus / 100),
skillPotential,
optimizationObjective: eventPtMode ? 'expected' : 'maximum',
...(eventBonus ? {
eventBonusPercent: eventBonus.totalPercent,
rankingMultiplier
} : {})
};
}
async function analyzeBaseline(): Promise<void> {
const targetWorker = baselineWorker;
const state = baselineState;
@@ -1010,12 +723,7 @@
return Math.ceil(maximumScore * ratio).toLocaleString('ko-KR');
}
function terminateParallelWorkers(): void {
for (const parallelWorker of parallelWorkers) parallelWorker.terminate();
parallelWorkers = [];
}
function finalizeRankings(rankings: RankedResult[]): void {
function selectRankings(rankings: RankedResult[]): RankedResult[] {
const bestByCombination = new Map<string, RankedResult>();
for (const ranking of rankings) {
const key = [...ranking.result.order].sort().join('|');
@@ -1024,90 +732,70 @@
bestByCombination.set(key, ranking);
}
}
rankedResults = Array.from(bestByCombination.values()).sort(compareRankedResults).slice(0, activeResultLimit);
result = rankedResults[0]?.result ?? null;
if (rankedResults.length > 0 && activeSearchChart && worker && rankedResults.every((ranking) => ranking.cards)) {
return Array.from(bestByCombination.values()).sort(compareRankedResults).slice(0, activeResultLimit);
}
function finalizeRankings(rankings: RankedResult[]): void {
const finalRankings = selectRankings(rankings);
if (finalRankings.length > 0 && activeSearchChart && worker && finalRankings.every((ranking) => ranking.cards)) {
searchPhase = 'probability';
progress = { evaluatedCandidates: 0, totalCandidates: rankedResults.length };
progress = { evaluatedCandidates: 0, totalCandidates: finalRankings.length };
worker.postMessage({
type: 'estimate-achievement',
rankings: rankedResults,
rankings: finalRankings,
chart: activeSearchChart,
sampleCount: 25_000
});
return;
}
running = false;
searchPhase = 'idle';
rankedResults = finalRankings;
result = rankedResults[0]?.result ?? null;
finishSearch();
}
function completeExactPass(rankings: RankedResult[]): void {
if (exactPass === 'primary' && activeSearchChart) {
primaryRankings = rankings;
const refinements = buildRefinementFinalists(rankings);
if (refinements.length > 0) {
async function completeExactPass(rankings: RankedResult[]): Promise<void> {
const requestId = activeSearchId;
if (!searchWorkers.isCurrent(requestId)) return;
try {
if (exactPass === 'primary' && activeSearchChart) {
primaryRankings = rankings;
exactPass = 'refinement';
exactCombinationCount += refinements.length;
if (maximizeCompute) runParallelExact(refinements, activeSearchChart);
else worker?.postMessage({ type: 'optimize-combinations', candidates: refinements, chart: activeSearchChart, resultLimit: activeResultLimit });
return;
progress = { evaluatedCandidates: 0, totalCandidates: 1 };
const { finalists: refinements } = await requestPreparation({
type: 'prepare-refinement', requestId,
rankings: rankings.map((ranking) => ({ result: { order: ranking.result.order } }))
});
if (!searchWorkers.isCurrent(requestId)) return;
if (refinements.length > 0) {
void runParallelExact(refinements, activeSearchChart);
return;
}
}
finalizeRankings([...primaryRankings, ...rankings]);
} catch (error) {
failSearch(error instanceof Error ? error.message : '추가 후보 준비에 실패했습니다.', requestId);
}
finalizeRankings([...primaryRankings, ...rankings]);
}
function runParallelExact(finalists: PreparedCandidate[], chart: ExactSongChart): void {
terminateParallelWorkers();
const reportedCores = typeof navigator === 'undefined' ? 1 : navigator.hardwareConcurrency || 4;
const workerCount = Math.min(finalists.length, Math.max(1, reportedCores));
const groups = Array.from({ length: workerCount }, () => [] as PreparedCandidate[]);
finalists.forEach((candidate, index) => groups[index % workerCount].push(candidate));
const groupProgress = Array.from({ length: workerCount }, () => 0);
const totalCandidates = finalists.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0);
const completedRankings: RankedResult[] = [];
let completedWorkers = 0;
let failed = false;
progress = { evaluatedCandidates: 0, totalCandidates };
const fail = (message: string) => {
if (failed) return;
failed = true;
terminateParallelWorkers();
errorMessage = message;
running = false;
searchPhase = 'idle';
};
groups.forEach((candidates, workerIndex) => {
const parallelWorker = createScoreOptimizerWorker();
parallelWorkers.push(parallelWorker);
parallelWorker.onmessage = (event) => {
if (failed) return;
if (event.data.type === 'progress') {
groupProgress[workerIndex] = event.data.progress.evaluatedCandidates;
progress = {
evaluatedCandidates: groupProgress.reduce((sum, value) => sum + value, 0),
totalCandidates
};
}
if (event.data.type === 'combination-result') {
const rankings = Array.isArray(event.data.rankings)
? event.data.rankings as RankedResult[]
: [{ result: event.data.result, overallPower: event.data.overallPower }];
completedRankings.push(...rankings);
completedWorkers += 1;
parallelWorker.terminate();
if (completedWorkers !== workerCount) return;
completedRankings.sort(compareRankedResults);
parallelWorkers = [];
completeExactPass(completedRankings.slice(0, activeResultLimit));
}
if (event.data.type === 'error') fail(event.data.message);
};
parallelWorker.onerror = () => fail('병렬 분석 Worker에서 오류가 발생했습니다.');
parallelWorker.postMessage({ type: 'optimize-combinations', candidates, chart, resultLimit: activeResultLimit });
});
async function runParallelExact(finalists: PreparedCandidate[], chart: ExactSongChart): Promise<void> {
const requestId = activeSearchId;
try {
if (!evaluationPool) {
const reported = typeof navigator === 'undefined' ? 1 : navigator.hardwareConcurrency || 4;
evaluationPool = new CombinationEvaluationPool(createScoreOptimizerWorker, searchWorkers, requestId,
maximizeCompute ? Math.min(32, finalists.length, reported) : 1);
}
const rankings = await evaluationPool.run(finalists, chart, {
resultLimit: activeResultLimit,
onPhase: phase => { if (searchWorkers.isCurrent(requestId)) searchPhase = phase; },
onProgress: value => { if (searchWorkers.isCurrent(requestId)) progress = value; }
});
if (!searchWorkers.isCurrent(requestId)) return;
exactCombinationCount += evaluationPool.lastExactCount;
await completeExactPass(rankings);
} catch (error) {
failSearch(error instanceof Error ? error.message : '후보 평가에 실패했습니다.', requestId);
}
}
async function search(): Promise<void> {
@@ -1125,7 +813,7 @@
return;
}
result = null;
if (!worker || !selectedChart) return;
if (!selectedChart) return;
if (ownedCardsOnly && new Set(candidateCards.map((card) => card.characterId)).size < 5) {
errorMessage = '서로 다른 멤버의 보유 5성 카드가 5장 이상 필요합니다.';
return;
@@ -1134,11 +822,15 @@
errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 고정할 수 없습니다.';
return;
}
if (!acceptsCombination(fixedCards)) {
if (!withinMediumProbabilityCardLimit(fixedCards, maxMediumProbabilityCards)) {
errorMessage = `고정 카드의 액티브 발동 확률 ‘중간’ 카드가 최대 ${maxMediumProbabilityCards}장 제한을 초과합니다. 고정 카드나 제한을 변경해 주세요.`;
return;
}
rankedResults = [];
running = true;
const requestId = searchWorkers.start();
activeSearchId = requestId;
searchNotice = '';
activeResultLimit = resultLimit;
searchSnapshot = structuredClone({ conditions: presetSnapshot,
growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
@@ -1149,30 +841,31 @@
primaryRankings = [];
screeningProgress = 0;
progress = { evaluatedCandidates: 0, totalCandidates: 1 };
await new Promise((resolve) => setTimeout(resolve, 0));
const metadata = selectedChart;
try {
const combinations = screeningCombinations();
screenedCombinationCount = combinations.length;
const prepared: PreparedCandidate[] = [];
for (let index = 0; index < combinations.length; index += 1) {
prepared.push(prepareCandidate(combinations[index]));
if (index % 50 === 49 || index === combinations.length - 1) {
screeningProgress = (index + 1) / combinations.length;
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
const finalists = selectExactFinalists(prepared);
exactCombinationCount = finalists.length;
preparationContext = structuredClone({
allCards, boardSummary, memoryEffects: memorySummary.effects, chartMetadata: metadata,
growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
settings: { fixedCardIds, fixedAwakenings, fixedCooldownReductions, fixedAllBlueNodes,
fixedGenerationBonusOverrides, candidateAwakening, candidateGenerationBonus, songScoreBonusOverride,
ownedCardsOnly, regularCardsOnly, maxMediumProbabilityCards, leaderCharacterId, leaderOutfitCardId },
eventMode, eventPtMode, currentEvent
});
resetScoreWorker(requestId);
preparationWorker = createCombinationPreparationWorker();
searchWorkers.track(preparationWorker, requestId);
const { finalists, screenedCount } = await requestPreparation({ type: 'prepare-search', requestId, context: preparationContext });
if (!searchWorkers.isCurrent(requestId)) return;
screenedCombinationCount = screenedCount ?? 0;
exactCombinationCount = 0;
if (finalists.length === 0) throw new Error('조건에 맞는 카드 조합이 없습니다.');
const chart = await loadExactSongChart(selectedChart.path);
const chart = await loadExactSongChart(metadata.path);
if (!searchWorkers.isCurrent(requestId)) return;
activeSearchChart = chart;
searchPhase = 'exact';
if (maximizeCompute) runParallelExact(finalists, chart);
else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart, resultLimit: activeResultLimit });
void runParallelExact(finalists, chart);
} catch (error) {
errorMessage = error instanceof Error ? error.message : '조합 탐색을 준비하지 못했습니다.';
running = false;
searchPhase = 'idle';
failSearch(error instanceof Error ? error.message : '조합 탐색을 준비하지 못했습니다.', requestId);
}
}
@@ -1236,6 +929,84 @@
.sort((left, right) => compareRankedResults(left, right) || Number(right.isBaseline) - Number(left.isBaseline))
.slice(0, limit);
}
function clearSearchWorkers(): void {
searchWorkers.cancel();
evaluationPool?.close();
evaluationPool = null;
worker = null;
preparationWorker = null;
preparationContext = null;
const reject = rejectPreparation;
rejectPreparation = null;
reject?.(new Error('취소된 탐색입니다.'));
}
function finishSearch(): void {
running = false;
searchPhase = 'idle';
clearSearchWorkers();
}
function failSearch(message: string, requestId = activeSearchId): void {
if (!searchWorkers.isCurrent(requestId)) return;
errorMessage = message;
finishSearch();
}
function cancelSearch(): void {
if (!running) return;
searchNotice = '탐색을 중지했습니다. 다시 탐색할 수 있습니다.';
finishSearch();
}
function resetScoreWorker(requestId: number): void {
const target = createScoreOptimizerWorker();
worker = target;
searchWorkers.track(target, requestId);
target.onmessage = (event) => {
if (!searchWorkers.owns(target, requestId)) return;
if (event.data.type === 'progress' || event.data.type === 'achievement-progress') progress = event.data.progress;
if (event.data.type === 'achievement-result') {
rankedResults = event.data.rankings as RankedResult[];
result = rankedResults[0]?.result ?? null;
finishSearch();
}
if (event.data.type === 'error') failSearch(event.data.message, requestId);
};
target.onerror = () => failSearch('정밀 평가 Worker에서 오류가 발생했습니다.', requestId);
}
function requestPreparation(request: PreparationRequest): Promise<{ finalists: PreparedCandidate[]; screenedCount?: number }> {
const target = preparationWorker;
const pass = request.type === 'prepare-search' ? 'primary' : 'refinement';
if (!target) return Promise.reject(new Error('후보 준비 Worker가 없습니다.'));
return new Promise((resolve, reject) => {
rejectPreparation = reject;
target.onmessage = (event) => {
if (!searchWorkers.owns(target, request.requestId) || event.data.requestId !== request.requestId || event.data.pass !== pass) return;
if (event.data.type === 'preparation-progress') {
screeningProgress = event.data.completed / Math.max(1, event.data.total);
}
if (event.data.type === 'preparation-result') {
if (rejectPreparation === reject) rejectPreparation = null;
resolve(event.data);
}
if (event.data.type === 'error') {
if (rejectPreparation === reject) rejectPreparation = null;
reject(new Error(event.data.message));
failSearch(event.data.message, request.requestId);
}
};
target.onerror = () => {
const message = '후보 준비 Worker에서 오류가 발생했습니다.';
if (!searchWorkers.owns(target, request.requestId)) return;
reject(new Error(message));
failSearch(message, request.requestId);
};
target.postMessage(request);
});
}
</script>
<svelte:head><title>{eventScoreMode ? '이벤트 점수 탐색' : eventMode ? '이벤트 PT 비교' : '조합 탐색'} · 갤산기</title></svelte:head>
@@ -1401,7 +1172,7 @@
<p class="candidate-guide">퍼센트는 현재 단계의 진행률입니다. 확장 후보가 없으면 3단계는 건너뜁니다.</p>
{/if}
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `${exactPass === 'refinement' ? '확장 후보 검증' : '선별 후보 평가'} ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
<label class="compute-option">
<label class="compute-option">
<input type="checkbox" bind:checked={maximizeCompute} disabled={running}>
<span aria-hidden="true"></span>
<strong>최대 컴퓨팅 자원 활용</strong>
@@ -1409,10 +1180,13 @@
<p class="compute-warning">탐색 속도는 빨라지지만, 분석 중 브라우저 및 기기 사용이 불편할 수 있습니다.</p>
{#if running}<progress value={searchPhase === 'screening' ? screeningProgress : progress.evaluatedCandidates} max={searchPhase === 'screening' ? 1 : progress.totalCandidates}></progress>{/if}
</fieldset>
{#if running}<button class="search cancel-search" type="button" on:click={cancelSearch}>탐색 중지</button>{/if}
{#if searchNotice}<p class="baseline-guide" role="status">{searchNotice}</p>{/if}
</section>
{#if !running}
<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>
<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 !eventMode}<p class="baseline-guide">상세 점수·그래프·액티브 미발동 비교는 각 결과의 ‘배치 분석에서 보기’를 눌러 새 탭에서 확인할 수 있습니다.</p>{/if}
{#if transferError}<p class="error" role="alert">{transferError}</p>{/if}
@@ -1485,6 +1259,7 @@
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
{/if}
</section>
{/if}
</main>
<style>