fix: apply event song bonus to final score
Publish GitHub Pages output / publish (push) Successful in 41s

This commit is contained in:
2026-10-04 02:54:02 +09:00
parent 2b26bbd94f
commit 4b8313c8a8
10 changed files with 135 additions and 7 deletions
+7 -1
View File
@@ -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 연결 실험 기능
+54
View File
@@ -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);
});
+17
View File
@@ -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) =>
+12
View File
@@ -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);
+2
View File
@@ -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;
+23
View File
@@ -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 });
+5 -4
View File
@@ -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
+3
View File
@@ -125,7 +125,10 @@ export type ExactScoreOptions = {
/** 지정한 카드의 해당 발동만 실패한 것으로 계산한다. */
excludedActiveWindows?: readonly Pick<SkillWindow, 'cardId' | 'startSeconds'>[];
perfectNoteScore: number;
/** 가창자 보너스. 노트별 점수에 적용한 뒤 올림한다. */
directSongScoreBonusPercent?: number;
/** 이벤트 과제곡 보너스. 노트 점수 합계에만 곱하며 노트별 점수에는 적용하지 않는다. */
finalScoreBonusPercent?: number;
};
export type ScoreAchievementProbabilities = {
@@ -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<OptimizeRequest | CombinationRequest | Ach
const options: ExactOptimizeOptions = {
perfectNoteScore: event.data.perfectNoteScore,
directSongScoreBonusPercent: event.data.directSongScoreBonusPercent,
finalScoreBonusPercent: event.data.finalScoreBonusPercent,
onProgress: (progress) => self.postMessage({ type: 'progress', progress })
};
const request = event.data;
@@ -111,6 +115,7 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
{
perfectNoteScore: ranking.result.perfectNoteScore,
directSongScoreBonusPercent: ranking.directSongScoreBonusPercent,
finalScoreBonusPercent: ranking.finalScoreBonusPercent,
sampleCount: request.sampleCount
}
);
@@ -132,6 +137,7 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
perfectNoteScore: candidate.perfectNoteScore,
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
finalScoreBonusPercent: candidate.finalScoreBonusPercent,
objective: candidate.optimizationObjective,
onProgress: (progress) => self.postMessage({
type: 'progress',
@@ -144,6 +150,7 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
overallPower: candidate.overallPower,
cards: candidate.cards,
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
finalScoreBonusPercent: candidate.finalScoreBonusPercent,
eventBonusPercent: candidate.eventBonusPercent,
rankingMultiplier: candidate.rankingMultiplier
};
+5 -2
View File
@@ -55,6 +55,7 @@
overallPower: number;
perfectNoteScore: number;
directSongScoreBonusPercent: number;
finalScoreBonusPercent: number;
heuristic: number;
skillPotential: number;
eventBonusPercent?: number;
@@ -66,6 +67,7 @@
overallPower: number;
cards?: OptimizerCard[];
directSongScoreBonusPercent?: number;
finalScoreBonusPercent?: number;
eventBonusPercent?: number;
rankingMultiplier?: number;
achievementProbabilities?: ScoreAchievementProbabilities;
@@ -888,13 +890,14 @@
const eventSongScoreBonus = currentEvent
? eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => 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 @@
<small>{eventPtMode ? 'PT 비교 지수' : eventScoreMode ? '이벤트 최고 점수' : '최고 점수'}</small>
<strong>{eventPtMode ? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0).toLocaleString('ko-KR') : ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>
{#if eventMode}<i>PT 획득 보너스 +{ranking.eventBonusPercent ?? 0}% · 평균 점수 {ranking.result.expectedScore.toLocaleString('ko-KR')}</i>
{#if currentEvent}<i>과제곡 스코어 +{eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, ranking.result.order)}%</i>{/if}
{#if currentEvent}<i>최종 점수에 과제곡 보너스 +{ranking.finalScoreBonusPercent ?? 0}%</i>{/if}
{/if}
{#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}
{#if eventMode}<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>