diff --git a/UPDATES.md b/UPDATES.md index 83ed610..851a658 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,7 +1,13 @@ --- -updatedAt: 2026-09-09T19:13:48+09:00 +updatedAt: 2026-09-10T10:52:51+09:00 --- +## 2026년 9월 10일 + +### 액티브 점수 영향력 표시 + +`NEW` 배치 분석에서 현재 설정의 최고점에 미치는 영향이 큰 액티브를 진한 색과 굵은 테두리로 강조합니다. 구간에 마우스를 올리면 토글 시 점수 변화를 확인할 수 있으며, 미발동 구간은 복원 시 영향력을 표시합니다. + ## 2026년 9월 9일 ### 조합 탐색 결과 출력 수 선택 diff --git a/src/lib/engine/active-impact.test.ts b/src/lib/engine/active-impact.test.ts new file mode 100644 index 0000000..73b1257 --- /dev/null +++ b/src/lib/engine/active-impact.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { activeWindowKey, calculateActiveImpacts } from './active-impact.ts'; +import { buildExactSkillWindows } from './exact-optimizer.ts'; +import type { ExactSongChart, OptimizerCard } from './types.ts'; + +test('영향력은 겹친 액티브와 스페셜을 반영하며 제외 후 복원 효과로 갱신된다', () => { + const cards: OptimizerCard[] = [100, 50].map((scoreUpPercent, index) => ({ + id: String(index), name: String(index), + active: { cooldownSeconds: 10, durationSeconds: 2, scoreUpPercent }, + special: { durationSeconds: 2, scoreUpPercent: index === 0 ? 100 : 0 } + })); + const chart: ExactSongChart = { + songId: 'synthetic', difficulty: 'expert', durationSeconds: 22, scoreRatio: 1, + notes: [10, 20].map((timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false })), + specialStartSeconds: [10, null] + }; + const windows = buildExactSkillWindows(cards, {}, chart).activeWindows; + const key = (cardId: string, startSeconds: number) => activeWindowKey({ cardId, startSeconds }); + const impacts = calculateActiveImpacts(cards, {}, chart, { perfectNoteScore: 1000 }, windows); + assert.deepEqual(impacts.get(key('0', 10)), { scoreChange: -1000, strength: 1 }); + assert.deepEqual(impacts.get(key('0', 20)), { scoreChange: -500, strength: 0.5 }); + assert.deepEqual(impacts.get(key('1', 10)), { scoreChange: 0, strength: 0 }); + const after = calculateActiveImpacts(cards, {}, chart, { + perfectNoteScore: 1000, excludedActiveWindows: [{ cardId: '0', startSeconds: 10 }] + }, windows); + assert.deepEqual(after.get(key('0', 10)), { scoreChange: 1000, strength: 1 }); + assert.deepEqual(after.get(key('1', 10)), { scoreChange: -1000, strength: 1 }); + const empty = calculateActiveImpacts(cards, {}, { ...chart, notes: [] }, { perfectNoteScore: 1000 }, windows); + assert.ok([...empty.values()].every((value) => value.scoreChange === 0 && value.strength === 0)); +}); diff --git a/src/lib/engine/active-impact.ts b/src/lib/engine/active-impact.ts new file mode 100644 index 0000000..a84067c --- /dev/null +++ b/src/lib/engine/active-impact.ts @@ -0,0 +1,31 @@ +import { calculateExactScore } from './score.ts'; +import type { CooldownReduction, ExactScoreOptions, ExactSongChart, OptimizerCard, SkillWindow } from './types.ts'; + +export function activeWindowKey(window: Pick): string { + return JSON.stringify([window.cardId, window.startSeconds]); +} + +/** Current-setting marginal maximum-score change when each activation is toggled. */ +export function calculateActiveImpacts( + cards: readonly OptimizerCard[], + reductions: Readonly>, + chart: ExactSongChart, + options: ExactScoreOptions, + windows: readonly SkillWindow[] +): Map { + const excluded = options.excludedActiveWindows ?? []; + const excludedKeys = new Set(excluded.map(activeWindowKey)); + const current = calculateExactScore(cards, reductions, chart, options).theoreticalMaxScore; + const changes = windows.map((window) => { + const key = activeWindowKey(window); + const toggled = excludedKeys.has(key) + ? excluded.filter((entry) => activeWindowKey(entry) !== key) + : [...excluded, window]; + const score = calculateExactScore(cards, reductions, chart, { ...options, excludedActiveWindows: toggled }); + return { key, scoreChange: score.theoreticalMaxScore - current }; + }); + const maximum = Math.max(0, ...changes.map((entry) => Math.abs(entry.scoreChange))); + return new Map(changes.map(({ key, scoreChange }) => [key, { + scoreChange, strength: maximum > 0 ? Math.abs(scoreChange) / maximum : 0 + }])); +} diff --git a/src/lib/engine/active-toggle-special.test.ts b/src/lib/engine/active-toggle-special.test.ts new file mode 100644 index 0000000..df6c8d9 --- /dev/null +++ b/src/lib/engine/active-toggle-special.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { calculateExactScore } from './score.ts'; +import { buildExactSkillWindows } from './exact-optimizer.ts'; +import type { ExactSongChart, OptimizerCard } from './types.ts'; + +const strong: OptimizerCard = { + id: 'strong', name: 'strong', + active: { cooldownSeconds: 10, durationSeconds: 4, scoreUpPercent: 100, probabilityPercent: 50 }, + special: { durationSeconds: 2, scoreUpPercent: 145 } +}; +const weak: OptimizerCard = { + id: 'weak', name: 'weak', + active: { cooldownSeconds: 10, durationSeconds: 4, scoreUpPercent: 50, probabilityPercent: 100 }, + special: { durationSeconds: 0, scoreUpPercent: 0 } +}; +const chart: ExactSongChart = { + songId: 'synthetic-special-overlap', difficulty: 'expert', durationSeconds: 25, scoreRatio: 1, + specialStartSeconds: [11, null], + notes: [10, 11, 12, 13, 14, 20].map((timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false })) +}; +const excludedActiveWindows = [{ cardId: 'strong', startSeconds: 10 }]; +const options = { perfectNoteScore: 1000 }; + +test('스페셜 중첩 발동을 제외해도 같은 카드의 스페셜은 대체 액티브에 유지된다', () => { + const before = calculateExactScore([strong, weak], {}, chart, options); + const after = calculateExactScore([strong, weak], {}, chart, { ...options, excludedActiveWindows }); + // 11~13초에는 액티브 100%/50%에 서포트 145%를 한 번만 적용한다. + assert.deepEqual(before.notes.map((note) => note.theoreticalMaxScore), [2000, 3450, 3450, 2000, 1000, 2000]); + assert.deepEqual(after.notes.map((note) => note.theoreticalMaxScore), [1500, 2225, 2225, 1500, 1000, 2000]); + assert.equal(before.theoreticalMaxScore - after.theoreticalMaxScore, 3450); + assert.deepEqual(before.notes.map((note, index) => note.theoreticalMaxScore - after.notes[index].theoreticalMaxScore), [500, 1225, 1225, 500, 0, 0]); + const windows = buildExactSkillWindows([strong, weak], {}, chart, excludedActiveWindows); + assert.deepEqual(windows.specialWindows, buildExactSkillWindows([strong, weak], {}, chart).specialWindows); + const winner = windows.effectiveActiveWindows.find((window) => window.startSeconds <= 11 && 11 < window.endSeconds); + assert.equal(winner?.cardId, 'weak'); + assert.ok(Math.abs((winner?.scoreUpPercent ?? 0) - 122.5) < 1e-9); + assert.deepEqual(calculateExactScore([strong, weak], {}, chart, { ...options, excludedActiveWindows: [] }), before); +}); + +test('대체 액티브가 없으면 스페셜 중첩 부분을 포함한 발동 전체의 증가분만 사라진다', () => { + const before = calculateExactScore([strong], {}, chart, options); + const after = calculateExactScore([strong], {}, chart, { ...options, excludedActiveWindows }); + assert.deepEqual(after.notes.map((note) => note.theoreticalMaxScore), [1000, 1000, 1000, 1000, 1000, 2000]); + assert.equal(before.theoreticalMaxScore - after.theoreticalMaxScore, 6900); + assert.equal(before.baselineScore, after.baselineScore); + // 최고점 변화는 성공을 가정한다. 발동률 50%를 적용한 기대값 변화와 다르다. + assert.equal(before.expectedScore - after.expectedScore, 3450); +}); + +test('스페셜이 중첩된 발동도 여러 토글의 증감 합과 원래 대비 차이가 일치한다', () => { + const cards = [strong, weak]; + const before = calculateExactScore(cards, {}, chart, options); + const first = calculateExactScore(cards, {}, chart, { ...options, excludedActiveWindows }); + const both = calculateExactScore(cards, {}, chart, { + ...options, excludedActiveWindows: [...excludedActiveWindows, { cardId: 'weak', startSeconds: 10 }] + }); + assert.equal(before.theoreticalMaxScore - first.theoreticalMaxScore, 3450); + assert.equal(first.theoreticalMaxScore - both.theoreticalMaxScore, 3450); + assert.equal(before.theoreticalMaxScore - both.theoreticalMaxScore, 6900); + const weakOnly = calculateExactScore(cards, {}, chart, { + ...options, excludedActiveWindows: [{ cardId: 'weak', startSeconds: 10 }] + }); + assert.equal(weakOnly.theoreticalMaxScore, before.theoreticalMaxScore); + assert.ok(weakOnly.expectedScore < before.expectedScore); +}); diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index 466f1c8..f2bfec0 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -1,6 +1,7 @@