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
+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>