diff --git a/UPDATES.md b/UPDATES.md index 7921ada..1043b1d 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -8,6 +8,10 @@ updatedAt: 2026-09-08T12:00:00+09:00 `DATA` 타카네 루이, 후와와, 모코코의 신규 5성 수영복 카드와 스킬·의상·메모리 데이터를 반영했습니다. +### 액티브 미발동 시뮬레이션 + +`NEW` 배치 분석의 카드별 액티브 구간을 클릭해 해당 발동을 제외한 예상 점수·이론상 고점과 그래프를 확인할 수 있습니다. 다시 클릭하거나 모두 복원하면 원래 결과로 돌아옵니다. + ### 연결 카드 최적화의 최고점 우선 적용 `FIXED` 배치 분석의 연결 카드 최적화에서 파란 영역의 연결 카드가 인근의 큰 발동률 노드를 반드시 포함하도록 했습니다. 이 조건 안에서 최고점을 최우선으로 비교하고, 최고점이 같을 때 액티브 발동률 UP 합계를 비교합니다. 조건을 충족할 수 없거나 최종 재계산에서 기존 배치보다 최고점이 낮아지면 기존 배치를 유지합니다. diff --git a/src/lib/engine/exact-optimizer.ts b/src/lib/engine/exact-optimizer.ts index 26650bb..bd8ff2c 100644 --- a/src/lib/engine/exact-optimizer.ts +++ b/src/lib/engine/exact-optimizer.ts @@ -99,10 +99,11 @@ function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): M return profiles; } -function resultWindows( +export function buildExactSkillWindows( order: readonly OptimizerCard[], reductions: Readonly>, - chart: ExactSongChart + chart: ExactSongChart, + excludedActiveWindows: readonly Pick[] = [] ): { activeWindows: SkillWindow[]; effectiveActiveWindows: EffectiveSkillWindow[]; specialWindows: SkillWindow[] } { const activeWindows = order.flatMap((card) => { const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reductions[card.id] ?? 0); @@ -143,7 +144,8 @@ function resultWindows( const midpoint = (startSeconds + endSeconds) / 2; const specialSupport = specialRate(order, chart, midpoint, 'scoreUpPercent'); const active = activeWindows - .filter((window) => window.startSeconds <= midpoint && midpoint < window.endSeconds) + .filter((window) => window.startSeconds <= midpoint && midpoint < window.endSeconds && + !excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && Math.abs(excluded.startSeconds - window.startSeconds) < 1e-9)) .map((window) => { const cardIndex = orderIndex.get(window.cardId) ?? 0; const card = order[cardIndex]; @@ -265,7 +267,7 @@ export function optimizeExactOrderAndCooldowns( } if (!bestOrder || !bestReductions) throw new Error('분석 결과를 만들지 못했습니다.'); - const windows = resultWindows(bestOrder, bestReductions, chart); + const windows = buildExactSkillWindows(bestOrder, bestReductions, chart); return { ...calculateExactScore(bestOrder, bestReductions, chart, options), order: bestOrder.map((card) => card.id), diff --git a/src/lib/engine/score.test.ts b/src/lib/engine/score.test.ts index 616dc2f..5f56fc6 100644 --- a/src/lib/engine/score.test.ts +++ b/src/lib/engine/score.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import scoreCalibrations from '../../../data/score-calibrations.json' with { type: 'json' }; -import { calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts'; +import { buildExactSkillWindows, calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts'; import type { ExactSongChart, OptimizerCard } from './types.ts'; const inactiveCard: OptimizerCard = { @@ -121,3 +121,35 @@ test('등록된 실측 ratio가 모든 1노트 측정값을 재현한다', () => } } }); + + +test('지정한 액티브만 실패 처리하고 겹친 다른 카드와 다음 발동은 유지한다', () => { + const strong: OptimizerCard = { + ...inactiveCard, id: 'strong', + active: { cooldownSeconds: 10, durationSeconds: 3, scoreUpPercent: 100, probabilityPercent: 50 } + }; + const weak: OptimizerCard = { + ...inactiveCard, id: 'weak', + active: { cooldownSeconds: 10, durationSeconds: 3, scoreUpPercent: 50, probabilityPercent: 100 } + }; + const interval = 10 / 1.04; + const song = chart([interval - 0.01, interval, interval + 3, interval * 2].map((timeSeconds) => ({ timeSeconds, type: 'regular' as const, isFlick: false }))); + const reductions = { strong: 4 as const, weak: 4 as const }; + const cards = [strong, weak]; + const original = calculateExactScore(cards, reductions, song, { perfectNoteScore: 100 }); + const excludedActiveWindows = [{ cardId: 'strong', startSeconds: interval }]; + const modified = calculateExactScore(cards, reductions, song, { perfectNoteScore: 100, excludedActiveWindows }); + assert.deepEqual(modified.notes.map((note) => note.theoreticalMaxScore), [100, 150, 100, 200]); + assert.deepEqual(modified.notes.map((note) => note.expectedScore), [100, 150, 100, 175]); + assert.equal(modified.baselineScore, original.baselineScore); + assert.equal(modified.scoreBySecond[9], 250); + assert.deepEqual(calculateExactScore(cards, reductions, song, { perfectNoteScore: 100, excludedActiveWindows: [] }), original); + const windows = buildExactSkillWindows(cards, reductions, song, excludedActiveWindows); + assert.equal(windows.effectiveActiveWindows.find((window) => window.startSeconds === interval)?.cardId, 'weak'); + assert.equal(windows.effectiveActiveWindows.find((window) => window.startSeconds === interval * 2)?.cardId, 'strong'); + assert.ok(windows.activeWindows.some((window) => window.cardId === 'strong' && window.startSeconds === interval)); + const bothExcluded = [...excludedActiveWindows, { cardId: 'weak', startSeconds: interval }]; + const noActive = calculateExactScore(cards, reductions, song, { perfectNoteScore: 100, excludedActiveWindows: bothExcluded }); + assert.equal(noActive.notes[1].theoreticalMaxScore, noActive.notes[1].baselineScore); + assert.ok(!buildExactSkillWindows(cards, reductions, song, bothExcluded).effectiveActiveWindows.some((window) => window.startSeconds === interval)); +}); diff --git a/src/lib/engine/score.ts b/src/lib/engine/score.ts index 08ce892..67abb0c 100644 --- a/src/lib/engine/score.ts +++ b/src/lib/engine/score.ts @@ -95,7 +95,8 @@ function activeCandidates( cardsInOrder: readonly OptimizerCard[], reductions: Readonly>, chart: ExactSongChart, - timeSeconds: number + timeSeconds: number, + excludedActiveWindows: ExactScoreOptions['excludedActiveWindows'] ): ActiveCandidate[] { const specialSupport = specialSupportAt(cardsInOrder, chart, timeSeconds); const candidates = cardsInOrder.flatMap((card, memberIndex) => { @@ -104,6 +105,7 @@ function activeCandidates( if (attempt <= 0) return []; const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction); const activationTime = attempt * interval; + if (excludedActiveWindows?.some((window) => window.cardId === card.id && Math.abs(window.startSeconds - activationTime) < 1e-9)) return []; if (timeSeconds >= activationTime + card.active.durationSeconds) return []; const activationRateBonus = activationRateBonusAt(cardsInOrder, chart, activationTime); const probability = effectiveActiveProbability(card, activationRateBonus); @@ -153,7 +155,7 @@ export function calculateExactScore( ? Math.ceil(options.perfectNoteScore * 0.1) : options.perfectNoteScore * (note.isFlick ? 1.05 : 1); const rawBaselineScore = baseNoteScore * (1 + comboBonus(combo)); - const candidates = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds); + const candidates = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds, options.excludedActiveWindows); const baselineScore = finalNoteScore(rawBaselineScore, directBonus); const expectedScore = finalNoteScore(rawBaselineScore * (1 + expectedStrongestBoost(candidates)), directBonus); const theoreticalMaxScore = finalNoteScore(rawBaselineScore * (1 + (candidates[0]?.boost ?? 0)), directBonus); diff --git a/src/lib/engine/song-score-formula.test.ts b/src/lib/engine/song-score-formula.test.ts index 2dd9658..5fa2f29 100644 --- a/src/lib/engine/song-score-formula.test.ts +++ b/src/lib/engine/song-score-formula.test.ts @@ -41,13 +41,15 @@ test('공식 적용 카탈로그와 실제 채보가 같은 분모 및 환산값 continue; } assert.equal(row.scoreCalibrationSource, 'formula-v1'); - assert.equal(row.scoreRatio, scoreRatioFromFormula(ref)); - assert.deepEqual(chart.scoreFormula, row.scoreFormula); - assert.deepEqual(chart.scoreFormula, { + assert.ok(typeof ref.liveScoreCoefficientPermil === 'number' && typeof ref.difficultyLevel === 'number'); + const formula = { perfectNoteWeight: ref.perfectNoteWeight, liveScoreCoefficientPermil: ref.liveScoreCoefficientPermil, difficultyLevel: ref.difficultyLevel - }); + }; + assert.equal(row.scoreRatio, scoreRatioFromFormula(formula)); + assert.deepEqual(chart.scoreFormula, row.scoreFormula); + assert.deepEqual(chart.scoreFormula, formula); assert.equal(chart.notes.length, ref.fullComboNoteCount); formulaCount += 1; } diff --git a/src/lib/engine/types.ts b/src/lib/engine/types.ts index b0443d1..db32939 100644 --- a/src/lib/engine/types.ts +++ b/src/lib/engine/types.ts @@ -118,6 +118,8 @@ export type ExactScoreResult = { }; export type ExactScoreOptions = { + /** 지정한 카드의 해당 발동만 실패한 것으로 계산한다. */ + excludedActiveWindows?: readonly Pick[]; perfectNoteScore: number; directSongScoreBonusPercent?: number; }; diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index f59d1c0..aaed24a 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -17,8 +17,8 @@ import PresetManager from '$lib/components/PresetManager.svelte'; import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte'; import SongPicker from '$lib/components/SongPicker.svelte'; - import { calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts'; - import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts'; + import { buildExactSkillWindows, calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts'; + import type { ExactScoreOptions, OptimizerCard, CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts'; import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage'; import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts'; @@ -114,6 +114,44 @@ let running = false; let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 }; let result: ExactOptimizationResult | null = null; + let simulationContext: { cards: OptimizerCard[]; chart: ExactSongChart; options: ExactScoreOptions } | null = null; + let simulationBase: ExactOptimizationResult | null = null; + let simulatedResult: ExactOptimizationResult | null = null; + let excludedActiveWindows: SkillWindow[] = []; + $: if (result !== simulationBase) { + simulationBase = result; + simulatedResult = null; + excludedActiveWindows = []; + } + $: displayedResult = simulatedResult ?? result; + + function isExcluded(window: SkillWindow): boolean { + return excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds); + } + + function toggleActiveWindow(window: SkillWindow): void { + if (!result || !simulationContext) return; + excludedActiveWindows = isExcluded(window) + ? excludedActiveWindows.filter((excluded) => excluded.cardId !== window.cardId || excluded.startSeconds !== window.startSeconds) + : [...excludedActiveWindows, window]; + if (!excludedActiveWindows.length) { + simulatedResult = null; + return; + } + const { cards, chart, options } = simulationContext; + const order = result.order.map((id) => cards.find((card) => card.id === id)!); + simulatedResult = { + ...result, + ...calculateExactScore(order, result.cooldownReductions, chart, { ...options, excludedActiveWindows }), + ...buildExactSkillWindows(order, result.cooldownReductions, chart, excludedActiveWindows) + }; + } + + function resetActiveWindows(): void { + excludedActiveWindows = []; + simulatedResult = null; + } + let ratingResultKey = ''; let resultCapture: AnalysisCapture | null = null; let pendingCapture: AnalysisCapture | null = null; @@ -176,7 +214,7 @@ $: inGamePerfectNoteScore = finalNoteScore(perfectNoteScore, boardEffects.directSongScoreBonusPercent); $: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'formula-v1' ? '곡별 공식' : selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값'; $: timelineDuration = selectedChart?.durationSeconds ?? 0; - $: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []); + $: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(displayedResult?.effectiveActiveWindows ?? []); $: timelineTicks = buildTimelineTicks(timelineDuration); $: timelineWidth = Math.max(720, timelineDuration * 6); $: candidateCount = (() => { @@ -749,6 +787,7 @@ })) }); const chart = await loadExactSongChart(selectedChart.path); + simulationContext = { cards, chart, options: { perfectNoteScore: noteScore, directSongScoreBonusPercent: songBonus } }; progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) }; worker.postMessage({ type: 'optimize-exact', @@ -1037,11 +1076,11 @@
-
예상 점수{result.expectedScore.toLocaleString('ko-KR')}
-
이론상 고점{result.theoreticalMaxScore.toLocaleString('ko-KR')}
+
예상 점수{displayedResult!.expectedScore.toLocaleString('ko-KR')}
+
이론상 고점{displayedResult!.theoreticalMaxScore.toLocaleString('ko-KR')}
스킬 미발동 기준{result.baselineScore.toLocaleString('ko-KR')}
- {#if mode === 'analysis' || achievementProbabilities} + {#if !simulatedResult && (mode === 'analysis' || achievementProbabilities)}
최고점 100%{probabilityLabel(achievementProbabilities?.maximum)}
최고점의 99% 이상{probabilityLabel(achievementProbabilities?.atLeast99Percent)}{scoreThreshold(result.theoreticalMaxScore, 0.99)}점 이상
@@ -1096,21 +1135,34 @@ {#each result.order as cardId}{resultCardName(cardId)}{/each}
+ {#if mode === 'analysis'} +

카드별 색칠된 구간을 클릭하면 해당 액티브만 미발동으로 계산합니다. 다시 클릭하면 복원됩니다. 배치와 쿨타임은 유지됩니다. + {#if excludedActiveWindows.length} + {excludedActiveWindows.length}회 미발동 적용 중 + + 미발동 적용 중에는 달성 확률을 표시하지 않습니다. 분석 기록에는 원래 분석 결과가 저장됩니다. + {/if} +

+ {/if}
- + {#each result.order as cardId, slot}
{slot + 1}{resultCardName(cardId)}
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window} - excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)} + disabled={mode !== 'analysis' || !simulationContext || running} style={coloredWindowStyle(window, timelineDuration)} - title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`} - role="img" - aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`} - > + title={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}% · 클릭하여 미발동/복원`} + aria-label={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 미발동 처리`} + aria-pressed={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)} + on:click={() => toggleActiveWindow(window)} + > {/each}
@@ -1290,7 +1342,13 @@ .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; } .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; } + .active-window { top: 8px; height: 20px; opacity: .72; padding: 0; border: 0; cursor: pointer; } + .active-window:hover:not(:disabled), .active-window:focus-visible { opacity: 1; outline: 2px solid #fff; outline-offset: 2px; } + .active-window.excluded { opacity: .28; border: 1px dashed var(--member-color); background: repeating-linear-gradient(135deg, transparent 0 3px, var(--member-color) 3px 4px); } + .active-window:disabled { cursor: default; } + .simulation-guide { color: #adb5c2; font-size: 13px; line-height: 1.7; } + .simulation-guide strong, .simulation-guide span { display: block; } + .simulation-guide button { margin: 4px 0; } .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%); }