From f953fe9970bac8941cbcf1b55b1f21760d4ce86e Mon Sep 17 00:00:00 2001 From: dal4segno Date: Tue, 18 Aug 2026 16:30:51 +0900 Subject: [PATCH] feat: add combination search workflow --- src/lib/components/AnalysisResultView.svelte | 217 +++++ src/lib/engine/board-effects.test.ts | 24 + src/lib/engine/board-effects.ts | 19 +- src/lib/engine/combination-search.test.ts | 22 + src/lib/engine/combination-search.ts | 55 ++ src/lib/engine/index.ts | 1 + src/lib/workers/score-optimizer.worker.ts | 62 +- src/routes/+layout.svelte | 1 + src/routes/analysis/+page.svelte | 20 +- src/routes/combination/+page.svelte | 897 +++++++++++++++++++ 10 files changed, 1294 insertions(+), 24 deletions(-) create mode 100644 src/lib/components/AnalysisResultView.svelte create mode 100644 src/lib/engine/combination-search.test.ts create mode 100644 src/lib/engine/combination-search.ts create mode 100644 src/routes/combination/+page.svelte diff --git a/src/lib/components/AnalysisResultView.svelte b/src/lib/components/AnalysisResultView.svelte new file mode 100644 index 0000000..c91ede2 --- /dev/null +++ b/src/lib/components/AnalysisResultView.svelte @@ -0,0 +1,217 @@ + + +
    + {#each result.order as cardId, slot} +
  1. {slot + 1}{cardName(cardId)}쿨타임 {result.cooldownReductions[cardId]}% 감소
  2. + {/each} +
+{#if showScoreSummary} +
+
예상 점수{result.expectedScore.toLocaleString('ko-KR')}{#if baseline}기준 대비 {delta(result.expectedScore, baseline.expectedScore)}{/if}
+
이론상 고점{result.theoreticalMaxScore.toLocaleString('ko-KR')}{#if baseline}기준 대비 {delta(result.theoreticalMaxScore, baseline.theoreticalMaxScore)}{/if}
+
스킬 미발동 기준{result.baselineScore.toLocaleString('ko-KR')}{#if baseline}기준 대비 {delta(result.baselineScore, baseline.baselineScore)}{/if}
+
+{/if} + +
+
+

스킬 발동과 노트 분포

멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.

+
+ {#each result.order as cardId}{cardName(cardId)}{/each} +
+
+
+
+
+ 초당 노트 +
+ {#each noteCountsBySecond as count, second} + + {/each} +
+
+ {#each result.order as cardId, slot} +
+ {slot + 1}{cardName(cardId)} +
+ {#each result.activeWindows.filter((window) => window.cardId === cardId) as window} + + {/each} +
+
+ {/each} +
+ 최종 액티브 +
+ {#each displayedEffectiveActiveWindows as window} + 1 ? ` · 최대 ${window.maxOverlapCount}명 중 가장 높은 효과` : ''}`} role="img" aria-label={`${cardName(window.cardId)} 액티브가 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}> + {/each} +
+
+
+ 스페셜 +
+ {#each result.specialWindows as window} + + {/each} +
+
+ +
+
+
+ + diff --git a/src/lib/engine/board-effects.test.ts b/src/lib/engine/board-effects.test.ts index 03ef7f4..f518e96 100644 --- a/src/lib/engine/board-effects.test.ts +++ b/src/lib/engine/board-effects.test.ts @@ -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); +}); diff --git a/src/lib/engine/board-effects.ts b/src/lib/engine/board-effects.ts index f374152..04e0cd7 100644 --- a/src/lib/engine/board-effects.ts +++ b/src/lib/engine/board-effects.ts @@ -26,6 +26,10 @@ export type BoardConnections = { placements: Readonly>>>; cards: readonly BoardConnectionCard[]; }; +export type BoardEffectOverrides = { + generationBonusByCardId?: Readonly>; + directSongScoreBonusPercent?: number; +}; export type BoardEffectsResult = { flatByCardId: Record>; rateByCardId: Record>; @@ -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) }; } diff --git a/src/lib/engine/combination-search.test.ts b/src/lib/engine/combination-search.test.ts new file mode 100644 index 0000000..127b597 --- /dev/null +++ b/src/lib/engine/combination-search.test.ts @@ -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']); +}); diff --git a/src/lib/engine/combination-search.ts b/src/lib/engine/combination-search.ts new file mode 100644 index 0000000..d59a467 --- /dev/null +++ b/src/lib/engine/combination-search.ts @@ -0,0 +1,55 @@ +export type CombinationSearchCard = { + id: string; + characterId: string; +}; + +type BeamState = { + cards: T[]; + score: number; +}; + +export function generateCombinationCandidates( + 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[] = [{ + 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>(); + 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); +} diff --git a/src/lib/engine/index.ts b/src/lib/engine/index.ts index 36969ca..b53e7d4 100644 --- a/src/lib/engine/index.ts +++ b/src/lib/engine/index.ts @@ -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'; diff --git a/src/lib/workers/score-optimizer.worker.ts b/src/lib/workers/score-optimizer.worker.ts index 15cb531..10b0c85 100644 --- a/src/lib/workers/score-optimizer.worker.ts +++ b/src/lib/workers/score-optimizer.worker.ts @@ -1,7 +1,7 @@ /// -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) => { - 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) => { 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', diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 7d56e52..64206b9 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -8,6 +8,7 @@ diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index 9c42fe3..3b68988 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -681,27 +681,27 @@ .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: 42px; } + .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: 78px; } - .note-track { height: 62px; align-self: center; border-bottom: 1px solid #465062; } + .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: 42px; } + .skill-track { height: 36px; } .skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); } - .active-window { top: 14px; height: 13px; opacity: .72; } + .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: 11px; height: 20px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); } - .special-window { top: 13px; height: 16px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 20%); } - .axis-row { min-height: 28px; border-top: 1px solid #343c49 !important; } + .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; } .axis-label { color: #778293; font-size: 10px; font-weight: 750; } - .time-axis { height: 28px; } + .time-axis { height: 24px; } .time-axis::before { display: none; } - .time-axis span { position: absolute; top: 7px; color: #747f90; font-size: 9px; transform: translateX(-50%); white-space: nowrap; } + .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%); } .empty { display: grid; place-items: center; min-height: 150px; margin-top: 18px; border: 1px dashed #303744; border-radius: 12px; padding: 28px; text-align: center; } diff --git a/src/routes/combination/+page.svelte b/src/routes/combination/+page.svelte new file mode 100644 index 0000000..127da41 --- /dev/null +++ b/src/routes/combination/+page.svelte @@ -0,0 +1,897 @@ + + +조합 탐색 · 홀로도리 + +
+

조합 탐색

+ {#if baselineState && baselineMatchesConditions} +
+
+
00

기준 조합

+ 배치 분석 저장값 +
+

{baselineChartMetadata?.title} · {baselineState.difficulty === 'expert' ? 'Expert' : 'Hard'}

+
+ {#each baselineResult?.order ?? baselineState.cardIds as cardId, slot} +
{slot + 1}{baselineCardLabel(cardId)}
+ {/each} +
+ {#if baselineResult} +
+
종합력{baselineOverallPower.toLocaleString('ko-KR')}
+
평균 점수{baselineResult.expectedScore.toLocaleString('ko-KR')}
+
최고 점수{baselineResult.theoreticalMaxScore.toLocaleString('ko-KR')}
+
+ {:else if baselineRunning} +

배치 분석의 저장 조건으로 기준 점수를 계산하고 있습니다.

+ + {:else if baselineError} + + {/if} +
+ {/if} +
+
01

탐색 조건

5성 전체 대상
+
+ + +
+
+
+ 자동 후보 개화 +
+ + {candidateAwakening} + +
+
+ +
+

기수 보너스는 녹색 노드를 활성화해 얻을 수 있는 보너스를 의미합니다.

+
+
+ +
파란 노드 전체기수 보너스
덮어쓰기
+
+ {#each fixedCardIds as selectedId, slot} +
+ +
+ 개화 +
+ + {fixedAwakenings[slot]} + +
+
+
+ 빈도 감소 고정 +
+ {#each REDUCTION_LOCK_OPTIONS as reduction} + + {/each} +
+
+
+ + updateFixedGenerationBonus(slot, event.currentTarget.value)}> +
+
+ {/each} +
+

‘파란 노드 전체’를 사용하지 않으면 사용자 데이터에 저장된 해당 멤버의 보드를 사용합니다.

+
+ + +
+
+ 리더 의상 효과 +

{leaderOutfitDescription || '적용할 수 있는 5성 의상이 없습니다.'}

+
+

기수 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·악곡 보너스 10% 조건으로 비교합니다.

+ {#if errorMessage}{/if} + + +

탐색 속도는 빨라지지만, 분석 중 브라우저 및 기기 사용이 불편할 수 있습니다.

+ {#if running}{/if} +
+ +
+
02

탐색 결과

{#if result}{screenedCombinationCount.toLocaleString('ko-KR')}개 선별 · {exactCombinationCount}개 정밀 분석 · 상위 {rankedResults.length}개{/if}
+ {#if rankedResults.length > 0} +
+ {#each rankedResults as ranking, rank} +
+ + {rank + 1}위 + + {#each ranking.result.order as cardId, memberIndex} + + {resultCardName(cardId)} + + {#if memberIndex < ranking.result.order.length - 1}{/if} + {/each} + + 최고 점수{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}{#if comparableToBaseline}{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}{/if} + +
+ +
+
+ {/each} +
+ {:else} +
고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.
+ {/if} +
+
+ +