fix: apply event song bonus to final score
Publish GitHub Pages output / publish (push) Successful in 41s
Publish GitHub Pages output / publish (push) Successful in 41s
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user