카드 조건과 빈도별 공통 계산을 재사용하고 준비·평가 Worker를 재사용합니다. 준비된 후보를 모두 정밀 평가하고 최종 결과를 한 번 표시합니다. 업데이트 문구를 한 항목으로 통합합니다.
This commit is contained in:
1 parent
f009a7fd6b
commit
da40ec4238
18 files changed
+1623
-467
No files matched your search
+174
-399
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user