From 4b8313c8a888b97995146771fe0f15ed39454b54 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Sun, 4 Oct 2026 02:54:02 +0900 Subject: [PATCH] fix: apply event song bonus to final score --- UPDATES.md | 8 +++- src/lib/engine/event-final-score.test.ts | 54 +++++++++++++++++++++++ src/lib/engine/exact-optimizer.test.ts | 17 +++++++ src/lib/engine/score-probability.test.ts | 12 +++++ src/lib/engine/score-probability.ts | 2 + src/lib/engine/score.test.ts | 23 ++++++++++ src/lib/engine/score.ts | 9 ++-- src/lib/engine/types.ts | 3 ++ src/lib/workers/score-optimizer.worker.ts | 7 +++ src/routes/combination/+page.svelte | 7 ++- 10 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 src/lib/engine/event-final-score.test.ts diff --git a/UPDATES.md b/UPDATES.md index d647652..30fa302 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,7 +1,13 @@ --- -updatedAt: 2026-10-03T06:42:48.753Z +updatedAt: 2026-10-04T00:00:00.000Z --- +## 2026년 10월 4일 + +### 이벤트 과제곡 점수 계산 수정 + +`FIX` 이벤트 과제곡 보너스를 가창 보너스와 더해 노트마다 적용하던 문제를 수정했습니다. 가창 보너스는 노트별 점수에 적용하고, 이벤트 과제곡 보너스는 노트 점수를 합산한 최종 점수에 곱합니다. 이벤트 최고점·평균 점수·PT 비교에 같은 계산식을 적용합니다. + ## 2026년 10월 3일 ### AI 연결 실험 기능 diff --git a/src/lib/engine/event-final-score.test.ts b/src/lib/engine/event-final-score.test.ts new file mode 100644 index 0000000..d21877e --- /dev/null +++ b/src/lib/engine/event-final-score.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { createContext, runInContext } from 'node:vm'; +import ts from 'typescript'; +import { optimizeExactOrderAndCooldowns, rankExactCooldownCombinations } from './exact-optimizer.ts'; +import { calculateMaximumScoreProbability, estimateScoreAchievementProbabilities } from './score-probability.ts'; +import type { ExactSongChart, OptimizerCard } from './types.ts'; + +const source = readFileSync(new URL('../workers/score-optimizer.worker.ts', import.meta.url), 'utf8'); +const code = ts.transpileModule(source.replace(/^import .*;\r?\n/gm, '').replace('export {};', ''), { + compilerOptions: { target: ts.ScriptTarget.ES2022 } +}).outputText; +const cards: OptimizerCard[] = Array.from({ length: 5 }, (_, i) => ({ + id: String(i), name: String(i), + active: { cooldownSeconds: 1, durationSeconds: 1, scoreUpPercent: 80 + i, probabilityPercent: 50 }, + special: { durationSeconds: 1, scoreUpPercent: 40 + i }, allowedCooldownReductions: [0] +})); +const chart: ExactSongChart = { songId: 'event', difficulty: 'expert', durationSeconds: 4, scoreRatio: 1, + notes: [1, 2, 3].map(timeSeconds => ({ timeSeconds, type: 'regular', isFlick: false })), + specialStartSeconds: [1, 2, 3, null, null] }; + +test('이벤트 워커는 최종 보너스로 조합을 정렬하고 확률 계산에도 같은 조건을 전달한다', () => { + const messages: any[] = []; + const context = createContext({ self: { postMessage: (message: unknown) => messages.push(message) }, + countOptimizationCandidates: () => 120, optimizeExactOrderAndCooldowns, rankExactCooldownCombinations, + calculateMaximumScoreProbability, estimateScoreAchievementProbabilities }); + runInContext(code, context); + context.request = { type: 'optimize-combinations', chart, candidates: [ + { cards, overallPower: 125, perfectNoteScore: 125, directSongScoreBonusPercent: 10 }, + { cards, overallPower: 120, perfectNoteScore: 120, directSongScoreBonusPercent: 10, finalScoreBonusPercent: 10 } + ] }; + runInContext('self.onmessage({ data: request });', context); + const ranked = messages.at(-1); + assert.equal(ranked.type, 'combination-result'); + assert.equal(ranked.rankings[0].overallPower, 120); + assert.equal(ranked.rankings[0].directSongScoreBonusPercent, 10); + assert.equal(ranked.rankings[0].finalScoreBonusPercent, 10); + const ordinary = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 120, directSongScoreBonusPercent: 10 }); + assert.equal(ranked.result.theoreticalMaxScore, ordinary.theoreticalMaxScore * 1.1); + assert.equal(JSON.stringify(ranked.result.notes), JSON.stringify(ordinary.notes)); + context.request = { type: 'estimate-achievement', rankings: ranked.rankings, chart, sampleCount: 1000 }; + runInContext('self.onmessage({ data: request });', context); + const probabilities = messages.at(-1); + assert.equal(probabilities.type, 'achievement-result'); + const ordered = ordinary.order.map(id => cards.find(c => c.id === id)!); + assert.equal(JSON.stringify(probabilities.rankings[0].achievementProbabilities), JSON.stringify( + estimateScoreAchievementProbabilities(ordered, ordinary.cooldownReductions, chart, + { perfectNoteScore: 120, directSongScoreBonusPercent: 10, sampleCount: 1000 }))); + context.request = { type: 'optimize-exact', cards, chart, perfectNoteScore: 120, + directSongScoreBonusPercent: 10, finalScoreBonusPercent: 10 }; + runInContext('self.onmessage({ data: request });', context); + assert.equal(messages.at(-1).result.theoreticalMaxScore, ordinary.theoreticalMaxScore * 1.1); +}); diff --git a/src/lib/engine/exact-optimizer.test.ts b/src/lib/engine/exact-optimizer.test.ts index 05df851..bef9d32 100644 --- a/src/lib/engine/exact-optimizer.test.ts +++ b/src/lib/engine/exact-optimizer.test.ts @@ -21,6 +21,23 @@ const chart: ExactSongChart = { specialStartSeconds: [1, 2, 3, 4, 5] }; +test('최종 이벤트 배율은 최적 순서·쿨타임을 유지하며 반환 점수에만 적용한다', () => { + const flexible = cards.map((card, index) => ({ ...card, + allowedCooldownReductions: index === 0 ? [0, 4] as const : [0] as const })); + for (const objective of ['maximum', 'expected'] as const) { + const options = { perfectNoteScore: 100.37, directSongScoreBonusPercent: 10, objective }; + const ordinary = rankExactCooldownCombinations(flexible, chart, options); + const event = rankExactCooldownCombinations(flexible, chart, { ...options, finalScoreBonusPercent: 10 }); + event.forEach((result, index) => { + assert.deepEqual(result.order, ordinary[index].order); + assert.deepEqual(result.cooldownReductions, ordinary[index].cooldownReductions); + assert.equal(result.theoreticalMaxScore, ordinary[index].theoreticalMaxScore * 1.1); + assert.equal(result.expectedScore, ordinary[index].expectedScore * 1.1); + assert.deepEqual(result.notes, ordinary[index].notes); + }); + } +}); + test('빈도 조합별 최적 순서를 최고점 순으로 최대 5개 반환한다', () => { const flexible = cards.map((card, index) => ({ ...card, allowedCooldownReductions: index < 3 ? [0, 4] as const : [0] as const })); const exhaustive = [0, 4].flatMap((a) => [0, 4].flatMap((b) => [0, 4].map((c) => diff --git a/src/lib/engine/score-probability.test.ts b/src/lib/engine/score-probability.test.ts index cd91de8..a71556b 100644 --- a/src/lib/engine/score-probability.test.ts +++ b/src/lib/engine/score-probability.test.ts @@ -30,6 +30,18 @@ function chart(times: number[], specialStartSeconds: (number | null)[] = []): Ex }; } +test('최종 이벤트 배율은 최고점과 최고점 대비 비율의 달성 확률을 바꾸지 않는다', () => { + const cards = [card('main', 100, 50), card('backup', 99, 60)]; + const song = chart([1.1, 2.1, 3.1]); + const options = { perfectNoteScore: 100.37, directSongScoreBonusPercent: 10, sampleCount: 1000, seed: 23 }; + const ordinary = estimateScoreAchievementProbabilities(cards, {}, song, options); + const eventOptions = { ...options, finalScoreBonusPercent: 10 }; + assert.deepEqual(estimateScoreAchievementProbabilities(cards, {}, song, eventOptions), ordinary); + assert.equal(calculateMaximumScoreProbability(cards, {}, song, eventOptions), ordinary.maximum); + assert.deepEqual(calculateMaximumScoreProbabilityTimeline(cards, {}, song, eventOptions), + calculateMaximumScoreProbabilityTimeline(cards, {}, song, options)); +}); + test('최고점 전용 계산은 중복 발동과 독립 발동 조건을 정확히 계산한다', () => { const options = { perfectNoteScore: 100 }; assert.equal(calculateMaximumScoreProbability([card('a', 100, 50, 0.9)], { a: 0 }, chart([1.1, 1.5]), options), 0.5); diff --git a/src/lib/engine/score-probability.ts b/src/lib/engine/score-probability.ts index e305af3..1ad520f 100644 --- a/src/lib/engine/score-probability.ts +++ b/src/lib/engine/score-probability.ts @@ -239,6 +239,8 @@ export function estimateScoreAchievementProbabilities( ): ScoreAchievementProbabilities { const model = prepareModel(cards, reductions, chart, options); const samples = Math.max(1, Math.round(options.sampleCount ?? 25_000)); + // A common final event multiplier cancels between each sampled total and + // its percentage-of-maximum threshold. Keep the model's note scores unchanged. const random = randomGenerator(options.seed ?? hashSeed(cards, reductions)); const active = new Uint8Array(model.attempts.length); let atLeast995 = 0; diff --git a/src/lib/engine/score.test.ts b/src/lib/engine/score.test.ts index 5f56fc6..3fdea9f 100644 --- a/src/lib/engine/score.test.ts +++ b/src/lib/engine/score.test.ts @@ -38,6 +38,29 @@ test('기본점수의 소수점을 유지하고 가창 보너스 적용 후 노 assert.equal(result.baselineScore, 1_490); }); +test('이벤트 보너스는 가창 보너스와 따로 노트 합계에 곱하고 노트 점수는 유지한다', () => { + const song = { ...chart([ + { timeSeconds: 1, type: 'regular' as const, isFlick: false }, + { timeSeconds: 1.5, type: 'regular' as const, isFlick: true }, + { timeSeconds: 2, type: 'mid' as const, isFlick: false } + ]), specialStartSeconds: [1] }; + const card = { ...inactiveCard, + active: { cooldownSeconds: 1, durationSeconds: 1, scoreUpPercent: 85, probabilityPercent: 50 }, + special: { durationSeconds: 2, scoreUpPercent: 120 } + }; + const options = { perfectNoteScore: 1354.507, directSongScoreBonusPercent: 10 }; + const ordinary = calculateExactScore([card], {}, song, options); + const event = calculateExactScore([card], {}, song, { ...options, finalScoreBonusPercent: 10 }); + for (const key of ['expectedScore', 'theoreticalMaxScore', 'baselineScore', 'withoutSpecialStartBonusScore'] as const) { + assert.equal(event[key], ordinary[key]! * 1.1); + } + assert.deepEqual(event.notes, ordinary.notes); + assert.deepEqual(event.scoreBySecond, ordinary.scoreBySecond); + assert.equal(event.perfectNoteScore, ordinary.perfectNoteScore); + assert.notEqual(event.theoreticalMaxScore, + calculateExactScore([card], {}, song, { ...options, directSongScoreBonusPercent: 20 }).theoreticalMaxScore); +}); + test('100콤보부터 1% 콤보 보너스를 적용한다', () => { const notes = Array.from({ length: 100 }, (_, index) => ({ timeSeconds: index, type: 'regular' as const, isFlick: false })); const result = calculateExactScore([inactiveCard], {}, chart(notes), { perfectNoteScore: 600 }); diff --git a/src/lib/engine/score.ts b/src/lib/engine/score.ts index 013382d..c2f3796 100644 --- a/src/lib/engine/score.ts +++ b/src/lib/engine/score.ts @@ -185,11 +185,12 @@ export function calculateExactScore( if (second >= 0 && second < scoreBySecond.length) scoreBySecond[second] += note.expectedScore; }); + const finalMultiplier = 1 + (options.finalScoreBonusPercent ?? 0) / 100; return { - expectedScore: rawExpected, - theoreticalMaxScore: rawMaximum, - withoutSpecialStartBonusScore: rawMaximum - specialStartBonus, - baselineScore: rawBaseline, + expectedScore: rawExpected * finalMultiplier, + theoreticalMaxScore: rawMaximum * finalMultiplier, + withoutSpecialStartBonusScore: (rawMaximum - specialStartBonus) * finalMultiplier, + baselineScore: rawBaseline * finalMultiplier, perfectNoteScore: options.perfectNoteScore, scoreBySecond, notes: notePoints diff --git a/src/lib/engine/types.ts b/src/lib/engine/types.ts index ac8aafc..54a580d 100644 --- a/src/lib/engine/types.ts +++ b/src/lib/engine/types.ts @@ -125,7 +125,10 @@ export type ExactScoreOptions = { /** 지정한 카드의 해당 발동만 실패한 것으로 계산한다. */ excludedActiveWindows?: readonly Pick[]; perfectNoteScore: number; + /** 가창자 보너스. 노트별 점수에 적용한 뒤 올림한다. */ directSongScoreBonusPercent?: number; + /** 이벤트 과제곡 보너스. 노트 점수 합계에만 곱하며 노트별 점수에는 적용하지 않는다. */ + finalScoreBonusPercent?: number; }; export type ScoreAchievementProbabilities = { diff --git a/src/lib/workers/score-optimizer.worker.ts b/src/lib/workers/score-optimizer.worker.ts index fab7c11..fb7bdd6 100644 --- a/src/lib/workers/score-optimizer.worker.ts +++ b/src/lib/workers/score-optimizer.worker.ts @@ -10,6 +10,7 @@ type OptimizeRequest = { chart: ExactSongChart; perfectNoteScore: number; directSongScoreBonusPercent?: number; + finalScoreBonusPercent?: number; includeAchievementProbabilities?: boolean; includeMaximumScoreProbability?: boolean; cooldownResultLimit?: number; @@ -20,6 +21,7 @@ type CombinationCandidate = { overallPower: number; perfectNoteScore: number; directSongScoreBonusPercent?: number; + finalScoreBonusPercent?: number; eventBonusPercent?: number; rankingMultiplier?: number; optimizationObjective?: 'maximum' | 'expected'; @@ -37,6 +39,7 @@ type RankedCombination = { overallPower: number; cards?: OptimizerCard[]; directSongScoreBonusPercent?: number; + finalScoreBonusPercent?: number; eventBonusPercent?: number; rankingMultiplier?: number; achievementProbabilities?: ScoreAchievementProbabilities; @@ -78,6 +81,7 @@ self.onmessage = (event: MessageEvent self.postMessage({ type: 'progress', progress }) }; const request = event.data; @@ -111,6 +115,7 @@ self.onmessage = (event: MessageEvent self.postMessage({ type: 'progress', @@ -144,6 +150,7 @@ self.onmessage = (event: MessageEvent card.id)) : 0; - const directSongScoreBonusPercent = boardEffects.directSongScoreBonusPercent + eventSongScoreBonus; + 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', @@ -1425,7 +1428,7 @@ {eventPtMode ? 'PT 비교 지수' : eventScoreMode ? '이벤트 최고 점수' : '최고 점수'} {eventPtMode ? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0).toLocaleString('ko-KR') : ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')} {#if eventMode}PT 획득 보너스 +{ranking.eventBonusPercent ?? 0}% · 평균 점수 {ranking.result.expectedScore.toLocaleString('ko-KR')} - {#if currentEvent}과제곡 스코어 +{eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, ranking.result.order)}%{/if} + {#if currentEvent}최종 점수에 과제곡 보너스 +{ranking.finalScoreBonusPercent ?? 0}%{/if} {/if} {#if comparableToBaseline && !ranking.isBaseline}{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}{/if} {#if eventMode}추가 개화 {displayedAdditionalAwakenings(ranking)}회