feat: add combination search workflow

This commit is contained in:
2026-08-18 16:30:51 +09:00
parent 7b713bffe3
commit f953fe9970
10 changed files with 1294 additions and 24 deletions
@@ -0,0 +1,217 @@
<script lang="ts">
import type { EffectiveSkillWindow, ExactOptimizationResult, SkillWindow } from '$lib/engine/index.ts';
type DisplayEffectiveWindow = SkillWindow & {
baseScoreUpPercent: number;
minScoreUpPercent: number;
maxScoreUpPercent: number;
maxOverlapCount: number;
};
export let result: ExactOptimizationResult;
export let durationSeconds: number;
export let cardName: (cardId: string) => string;
export let instanceId = 'result';
export let baseline: ExactOptimizationResult | null = null;
export let showScoreSummary = true;
const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const;
$: noteCountsBySecond = countNotesBySecond(result, durationSeconds);
$: maxNotesPerSecond = Math.max(1, ...noteCountsBySecond);
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result.effectiveActiveWindows ?? []);
$: timelineTicks = buildTimelineTicks(durationSeconds);
$: timelineWidth = Math.max(720, durationSeconds * 6);
function countNotesBySecond(scoreResult: ExactOptimizationResult, duration: number): number[] {
const counts = Array.from({ length: Math.ceil(duration) }, () => 0);
for (const note of scoreResult.notes) {
const second = Math.floor(note.timeSeconds);
if (second >= 0 && second < counts.length) counts[second] += 1;
}
return counts;
}
function buildTimelineTicks(duration: number): number[] {
if (duration <= 0) return [];
const ticks = Array.from({ length: Math.floor(duration / 10) + 1 }, (_, index) => index * 10);
if (ticks.at(-1) !== duration) ticks.push(duration);
return ticks;
}
function timelinePosition(seconds: number): number {
return durationSeconds > 0 ? Math.max(0, Math.min(100, seconds / durationSeconds * 100)) : 0;
}
function skillWindowStyle(startSeconds: number, endSeconds: number): string {
return `left: ${timelinePosition(startSeconds)}%; width: ${Math.max(0, timelinePosition(endSeconds) - timelinePosition(startSeconds))}%;`;
}
function timelineTrackStyle(): string {
return `width: ${timelineWidth}px; --time-grid-step: ${timelinePosition(10)}%;`;
}
function memberColor(cardId: string): string {
const index = result.order.indexOf(cardId);
return MEMBER_COLORS[index >= 0 ? index % MEMBER_COLORS.length : 0];
}
function coloredWindowStyle(window: SkillWindow): string {
return `${skillWindowStyle(window.startSeconds, window.endSeconds)} --member-color: ${memberColor(window.cardId)};`;
}
function mergeEffectiveActiveWindows(windows: readonly EffectiveSkillWindow[]): DisplayEffectiveWindow[] {
const merged: DisplayEffectiveWindow[] = [];
for (const window of windows) {
const previous = merged.at(-1);
if (previous?.cardId === window.cardId && Math.abs(previous.endSeconds - window.startSeconds) < 0.001) {
previous.endSeconds = window.endSeconds;
previous.minScoreUpPercent = Math.min(previous.minScoreUpPercent, window.scoreUpPercent);
previous.maxScoreUpPercent = Math.max(previous.maxScoreUpPercent, window.scoreUpPercent);
previous.maxOverlapCount = Math.max(previous.maxOverlapCount, window.overlapCount);
continue;
}
merged.push({
...window,
minScoreUpPercent: window.scoreUpPercent,
maxScoreUpPercent: window.scoreUpPercent,
maxOverlapCount: window.overlapCount
});
}
return merged;
}
function effectivePercentLabel(window: DisplayEffectiveWindow): string {
const minimum = Number(window.minScoreUpPercent.toFixed(1));
const maximum = Number(window.maxScoreUpPercent.toFixed(1));
return minimum === maximum ? `${minimum}%` : `${minimum}~${maximum}%`;
}
function formatSeconds(seconds: number): string {
return `${Number(seconds.toFixed(1)).toLocaleString('ko-KR')}초`;
}
function delta(current: number, reference: number): string {
const difference = current - reference;
return `${difference >= 0 ? '+' : ''}${difference.toLocaleString('ko-KR')}`;
}
</script>
<ol class="order-list">
{#each result.order as cardId, slot}
<li><span class="slot">{slot + 1}</span><strong>{cardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
{/each}
</ol>
{#if showScoreSummary}
<div class="score-grid">
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.expectedScore, baseline.expectedScore)}</small>{/if}</div>
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.theoreticalMaxScore, baseline.theoreticalMaxScore)}</small>{/if}</div>
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.baselineScore, baseline.baselineScore)}</small>{/if}</div>
</div>
{/if}
<section class="skill-timeline" aria-labelledby={`timeline-title-${instanceId}`}>
<div class="timeline-heading">
<div><h3 id={`timeline-title-${instanceId}`}>스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div>
<div class="timeline-legend" aria-label="멤버 색상 범례">
{#each result.order as cardId}<span><i style={`background: ${memberColor(cardId)};`}></i>{cardName(cardId)}</span>{/each}
</div>
</div>
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프">
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
<div class="timeline-row note-row">
<strong class="lane-label">초당 노트</strong>
<div class="timeline-track note-track" style={timelineTrackStyle()} aria-label={`최대 초당 ${maxNotesPerSecond}개`}>
{#each noteCountsBySecond as count, second}
<span class="note-bar" style={`left: ${timelinePosition(second)}%; width: ${100 / Math.max(1, noteCountsBySecond.length)}%; height: ${count / maxNotesPerSecond * 100}%; opacity: ${count === 0 ? 0 : 0.18 + count / maxNotesPerSecond * 0.82};`} title={`${second}~${second + 1}초 · ${count}개`}></span>
{/each}
</div>
</div>
{#each result.order as cardId, slot}
<div class="timeline-row member-row">
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{cardName(cardId)}</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
<span class="skill-window active-window" style={coloredWindowStyle(window)} title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`} role="img" aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`}></span>
{/each}
</div>
</div>
{/each}
<div class="timeline-row summary-row">
<strong class="lane-label summary-label">최종 액티브</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each displayedEffectiveActiveWindows as window}
<span class="skill-window effective-window" style={coloredWindowStyle(window)} title={`${cardName(window.cardId)} 액티브 적용 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · 기본 ${window.baseScoreUpPercent}% → 서포트 반영 ${effectivePercentLabel(window)}${window.maxOverlapCount > 1 ? ` · 최대 ${window.maxOverlapCount}명 중 가장 높은 효과` : ''}`} role="img" aria-label={`${cardName(window.cardId)} 액티브가 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}></span>
{/each}
</div>
</div>
<div class="timeline-row summary-row">
<strong class="lane-label summary-label">스페셜</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each result.specialWindows as window}
<span class="skill-window special-window" style={coloredWindowStyle(window)} title={`${cardName(window.cardId)} 스페셜 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`} role="img" aria-label={`${cardName(window.cardId)} 스페셜이 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}></span>
{/each}
</div>
</div>
<div class="timeline-row axis-row" aria-hidden="true">
<span class="lane-label axis-label"></span>
<div class="timeline-track time-axis" style={timelineTrackStyle()}>
{#each timelineTicks as second}<span style={`left: ${timelinePosition(second)}%;`}>{second}초</span>{/each}
</div>
</div>
</div>
</div>
</section>
<style>
.order-list { display: grid; gap: 8px; padding: 0; margin: 18px 0 0; list-style: none; }
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reduction { padding: 6px 8px; border-radius: 7px; color: #b9c1cd; background: #252c36; font-size: 11px; }
.score-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
.score-summary span { color: #a9b1be; font-size: 12px; }
.score-summary strong { color: #f2a3c4; font-size: 20px; }
.score-summary small { color: #90d8ae; font-size: 10px; font-weight: 800; }
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; }
.timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
.timeline-heading h3 { margin: 0; color: #e8ebf1; font-size: 16px; }
.timeline-heading p { margin: 6px 0 0; color: #838d9d; font-size: 11px; }
.timeline-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; color: #99a2b1; font-size: 10px; }
.timeline-legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.timeline-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
.timeline-scroll { overflow-x: auto; border: 1px solid #2c3440; border-radius: 11px; background: #10141a; scrollbar-width: thin; }
.timeline-canvas { min-width: 100%; padding: 12px 12px 8px 0; }
.timeline-row { display: flex; min-height: 36px; }
.timeline-row + .timeline-row { border-top: 1px solid rgb(255 255 255 / 5%); }
.lane-label { position: sticky; left: 0; z-index: 4; display: flex; align-items: center; gap: 7px; flex: 0 0 144px; min-width: 144px; padding: 0 10px 0 12px; overflow: hidden; color: #bac2ce; background: #141920; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.lane-label small { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border: 1px solid color-mix(in srgb, var(--member-color) 65%, transparent); border-radius: 5px; color: var(--member-color); background: color-mix(in srgb, var(--member-color) 12%, transparent); font-size: 9px; }
.timeline-track { position: relative; flex: none; }
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
.note-row { min-height: 68px; }
.note-track { height: 52px; align-self: center; border-bottom: 1px solid #465062; }
.note-bar { position: absolute; bottom: 0; min-height: 1px; border-right: 1px solid #10141a; background: #8ca9d7; }
.skill-track { height: 36px; }
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
.active-window { top: 12px; height: 12px; opacity: .72; }
.summary-row { background: rgb(255 255 255 / 1.5%); }
.summary-label { color: #e1e5ec; font-weight: 800; }
.effective-window { top: 9px; height: 18px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); }
.special-window { top: 11px; height: 14px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 20%); }
.axis-row { min-height: 24px; border-top: 1px solid #343c49 !important; }
.time-axis { height: 24px; }
.time-axis::before { display: none; }
.time-axis span { position: absolute; top: 5px; color: #747f90; font-size: 9px; transform: translateX(-50%); white-space: nowrap; }
.time-axis span:first-child { transform: none; }
.time-axis span:last-child { transform: translateX(-100%); }
@media (max-width: 700px) {
.order-list li { grid-template-columns: 30px minmax(0, 1fr); }
.reduction { grid-column: 2; justify-self: start; }
.score-grid { grid-template-columns: 1fr; }
.timeline-heading { align-items: flex-start; flex-direction: column; }
.timeline-legend { justify-content: flex-start; }
.lane-label { flex-basis: 118px; min-width: 118px; }
.timeline-canvas { padding-right: 8px; }
}
</style>
+24
View File
@@ -14,6 +14,7 @@ const summary: BoardSummarySource = {
type: 'live_active_skill_effect_up_permil_up', value: 100, targetId: 'skill_tree_effect_target-all', trigger: 'set_live_leader'
} }],
'G-1': [{ characterIds: [], effect: { type: 'all_parameter_up', value: 5, targetId: 'skill_tree_effect_target-all', trigger: 'always' } }],
'G-2': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 50, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_0', trigger: 'always' } }],
'Y-1': [{ characterIds: [], effect: {
id: 'skill_tree_effect-live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type-5-skill_tree_effect_passive_trigger-music_skill_tree_character_and_music_singer_type-solo-always-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 5, trigger: 'always'
@@ -63,3 +64,26 @@ test('연결 카드가 범위 안의 활성 노드 효과를 증폭한다', () =
assert.equal(result.flatByCardId['card-a'].performance, 300);
});
test('조합 탐색용 기수 보너스와 악곡 보너스는 실제 노드 합계를 대체한다', () => {
const result = calculateBoardEffects(summary, { account: ['G-2', 'Y-1'] }, cards, 'a', {
singerType: 'solo', singerCharacterIds: ['a']
}, null, {
generationBonusByCardId: { 'card-a': 900 },
directSongScoreBonusPercent: 10
});
assert.equal(result.flatByCardId['card-a'].performance, 900);
assert.equal(result.flatByCardId['card-a'].technique, 900);
assert.equal(result.directSongScoreBonusPercent, 10);
const zeroOverride = calculateBoardEffects(summary, { account: ['G-2'] }, cards, 'a', null, null, {
generationBonusByCardId: { 'card-a': 0 }
});
const customOverride = calculateBoardEffects(summary, { account: ['G-2'] }, cards, 'a', null, null, {
generationBonusByCardId: { 'card-a': 450 }
});
assert.equal(zeroOverride.flatByCardId['card-a'].performance, 0);
assert.equal(customOverride.flatByCardId['card-a'].performance, 450);
});
+16 -3
View File
@@ -26,6 +26,10 @@ export type BoardConnections = {
placements: Readonly<Record<string, Readonly<Record<string, string>>>>;
cards: readonly BoardConnectionCard[];
};
export type BoardEffectOverrides = {
generationBonusByCardId?: Readonly<Record<string, number>>;
directSongScoreBonusPercent?: number;
};
export type BoardEffectsResult = {
flatByCardId: Record<string, Record<UnitStat, number>>;
rateByCardId: Record<string, Record<UnitStat, number>>;
@@ -126,7 +130,8 @@ export function calculateBoardEffects(
cards: readonly BoardEffectCard[],
leaderCharacterId: string,
song?: BoardEffectSong | null,
connections?: BoardConnections | null
connections?: BoardConnections | null,
overrides?: BoardEffectOverrides | null
): BoardEffectsResult {
const flatByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
const rateByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
@@ -167,12 +172,20 @@ export function calculateBoardEffects(
const destination = effect.type.endsWith('_permil_up') ? rateByCardId : flatByCardId;
const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value;
for (const card of targets(effect, ownerId, cards)) {
const effectTargets = targets(effect, ownerId, cards).filter((card) =>
effect.type !== 'all_parameter_up_for_character_grouping' || overrides?.generationBonusByCardId?.[card.id] === undefined
);
for (const card of effectTargets) {
for (const stat of affectedStats(effect.type)) destination[card.id][stat] += normalizedValue;
}
}
}
for (const [cardId, value] of Object.entries(overrides?.generationBonusByCardId ?? {})) {
if (!flatByCardId[cardId]) continue;
for (const stat of stats) flatByCardId[cardId][stat] += value;
}
// 쿨타임 감소는 현재 보드 상태가 아니라 최적화 변수다. 모든 멤버가 네 경우를 항상 탐색한다.
const allowedCooldownReductionsByCardId = Object.fromEntries(cards.map((card) => [
card.id,
@@ -184,6 +197,6 @@ export function calculateBoardEffects(
activationRateUpByCardId,
allowedCooldownReductionsByCardId,
scoreSupportPercent,
directSongScoreBonusPercent: Math.min(10, directSongScoreBonusPercent)
directSongScoreBonusPercent: overrides?.directSongScoreBonusPercent ?? Math.min(10, directSongScoreBonusPercent)
};
}
+22
View File
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { generateCombinationCandidates } from './index.ts';
const cards = [
{ id: 'a-1', characterId: 'a', score: 10 },
{ id: 'a-2', characterId: 'a', score: 20 },
{ id: 'b-1', characterId: 'b', score: 30 },
{ id: 'c-1', characterId: 'c', score: 40 }
];
test('고정 카드를 유지하고 같은 멤버의 이격 카드를 제외한다', () => {
const result = generateCombinationCandidates([cards[0]], cards, 3, (card) => card.score, 20);
assert.equal(result.length, 1);
assert.deepEqual(result[0].map((card) => card.id).sort(), ['a-1', 'b-1', 'c-1']);
});
test('빔 너비만큼 점수가 높은 조합을 남긴다', () => {
const result = generateCombinationCandidates([], cards, 2, (card) => card.score, 2);
assert.equal(result.length, 2);
assert.deepEqual(result[0].map((card) => card.id).sort(), ['b-1', 'c-1']);
});
+55
View File
@@ -0,0 +1,55 @@
export type CombinationSearchCard = {
id: string;
characterId: string;
};
type BeamState<T extends CombinationSearchCard> = {
cards: T[];
score: number;
};
export function generateCombinationCandidates<T extends CombinationSearchCard>(
fixedCards: readonly T[],
candidateCards: readonly T[],
targetSize: number,
scoreCard: (card: T) => number,
beamWidth = 800
): T[][] {
if (targetSize < fixedCards.length) throw new Error('목표 카드 수가 고정 카드 수보다 작습니다.');
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
throw new Error('같은 멤버를 두 번 고정할 수 없습니다.');
}
const fixedIds = new Set(fixedCards.map((card) => card.id));
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
const pool = candidateCards.filter((card) => !fixedIds.has(card.id) && !fixedCharacters.has(card.characterId));
let beam: BeamState<T>[] = [{
cards: Array.from(fixedCards),
score: fixedCards.reduce((sum, card) => sum + scoreCard(card), 0)
}];
for (let size = fixedCards.length; size < targetSize; size += 1) {
const expanded = new Map<string, BeamState<T>>();
for (const state of beam) {
const usedCharacters = new Set(state.cards.map((card) => card.characterId));
const usedIds = new Set(state.cards.map((card) => card.id));
for (const card of pool) {
if (usedIds.has(card.id)) continue;
if (usedCharacters.has(card.characterId)) continue;
const nextState = {
cards: [...state.cards, card],
score: state.score + scoreCard(card)
};
const key = nextState.cards.map((entry) => entry.id).sort().join('|');
if (!expanded.has(key)) expanded.set(key, nextState);
}
}
beam = Array.from(expanded.values())
.sort((left, right) => right.score - left.score || left.cards.map((card) => card.id).join().localeCompare(right.cards.map((card) => card.id).join()))
.slice(0, Math.max(1, beamWidth));
if (beam.length === 0) break;
}
return beam
.filter((state) => state.cards.length === targetSize)
.sort((left, right) => right.score - left.score)
.map((state) => state.cards);
}
+1
View File
@@ -1,4 +1,5 @@
export * from './card-adapter.ts';
export * from './combination-search.ts';
export * from './board-effects.ts';
export * from './exact-optimizer.ts';
export * from './memory.ts';
+51 -11
View File
@@ -1,7 +1,7 @@
/// <reference lib="webworker" />
import { optimizeExactOrderAndCooldowns } from '../engine/index.ts';
import type { ExactOptimizeOptions, ExactSongChart, OptimizerCard } from '../engine/index.ts';
import { countOptimizationCandidates, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard } from '../engine/index.ts';
type OptimizeRequest = {
type: 'optimize-exact';
@@ -11,17 +11,57 @@ type OptimizeRequest = {
directSongScoreBonusPercent?: number;
};
self.onmessage = (event: MessageEvent<OptimizeRequest>) => {
if (event.data.type !== 'optimize-exact') return;
type CombinationCandidate = {
cards: OptimizerCard[];
overallPower: number;
perfectNoteScore: number;
directSongScoreBonusPercent?: number;
};
type CombinationRequest = {
type: 'optimize-combinations';
candidates: CombinationCandidate[];
chart: ExactSongChart;
};
self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) => {
try {
const options: ExactOptimizeOptions = {
perfectNoteScore: event.data.perfectNoteScore,
directSongScoreBonusPercent: event.data.directSongScoreBonusPercent,
onProgress: (progress) => self.postMessage({ type: 'progress', progress })
};
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
self.postMessage({ type: 'result', result });
if (event.data.type === 'optimize-exact') {
const options: ExactOptimizeOptions = {
perfectNoteScore: event.data.perfectNoteScore,
directSongScoreBonusPercent: event.data.directSongScoreBonusPercent,
onProgress: (progress) => self.postMessage({ type: 'progress', progress })
};
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
self.postMessage({ type: 'result', result });
return;
}
const totalCandidates = event.data.candidates.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0);
let completedCandidates = 0;
let best: { result: ExactOptimizationResult; overallPower: number } | null = null;
const rankings: { result: ExactOptimizationResult; overallPower: number }[] = [];
for (const candidate of event.data.candidates) {
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
perfectNoteScore: candidate.perfectNoteScore,
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
onProgress: (progress) => self.postMessage({
type: 'progress',
progress: { evaluatedCandidates: completedCandidates + progress.evaluatedCandidates, totalCandidates }
})
});
completedCandidates += countOptimizationCandidates(candidate.cards);
if (!best || result.theoreticalMaxScore > best.result.theoreticalMaxScore ||
(result.theoreticalMaxScore === best.result.theoreticalMaxScore && result.expectedScore > best.result.expectedScore)) {
best = { result, overallPower: candidate.overallPower };
}
rankings.push({ result, overallPower: candidate.overallPower });
}
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
rankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
right.result.expectedScore - left.result.expectedScore);
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, 10) });
} catch (error) {
self.postMessage({
type: 'error',