153 lines
5.9 KiB
TypeScript
153 lines
5.9 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { estimateScoreAchievementProbabilities } from './index.ts';
|
|
import { calculateMaximumScoreProbabilityTimeline } from './score-probability.ts';
|
|
import type { ExactSongChart, OptimizerCard } from './types.ts';
|
|
|
|
function card(
|
|
id: string,
|
|
scoreUpPercent: number,
|
|
probabilityPercent: number,
|
|
durationSeconds = 1
|
|
): OptimizerCard {
|
|
return {
|
|
id,
|
|
name: id,
|
|
active: { cooldownSeconds: 1, durationSeconds, scoreUpPercent, probabilityPercent },
|
|
special: { durationSeconds: 0, scoreUpPercent: 0 },
|
|
allowedCooldownReductions: [0]
|
|
};
|
|
}
|
|
|
|
function chart(times: number[], specialStartSeconds: (number | null)[] = []): ExactSongChart {
|
|
return {
|
|
songId: 'test',
|
|
difficulty: 'expert',
|
|
durationSeconds: 5,
|
|
scoreRatio: 1,
|
|
notes: times.map((timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false })),
|
|
specialStartSeconds
|
|
};
|
|
}
|
|
|
|
test('한 번의 발동이 여러 노트에 영향을 주어도 최고점 확률은 한 번만 곱한다', () => {
|
|
const result = estimateScoreAchievementProbabilities(
|
|
[card('main', 100, 50, 0.9)],
|
|
{ main: 0 },
|
|
chart([1.1, 1.5]),
|
|
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 1 }
|
|
);
|
|
assert.equal(result.maximum, 0.5);
|
|
});
|
|
|
|
test('최고점에 영향을 주지 않는 약한 액티브는 최고점 확률에서 제외한다', () => {
|
|
const result = estimateScoreAchievementProbabilities(
|
|
[card('strong', 100, 50), card('weak', 20, 10)],
|
|
{ strong: 0, weak: 0 },
|
|
chart([1.1]),
|
|
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 2 }
|
|
);
|
|
assert.equal(result.maximum, 0.5);
|
|
});
|
|
|
|
test('같은 최고 효과의 액티브가 겹치면 둘 중 하나가 발동할 확률을 사용한다', () => {
|
|
const result = estimateScoreAchievementProbabilities(
|
|
[card('left', 100, 50), card('right', 100, 50)],
|
|
{ left: 0, right: 0 },
|
|
chart([1.1]),
|
|
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 3 }
|
|
);
|
|
assert.equal(result.maximum, 0.75);
|
|
});
|
|
|
|
test('스페셜의 액티브 발동률 증가를 발동 시점에 적용한다', () => {
|
|
const main = card('main', 100, 50);
|
|
const support: OptimizerCard = {
|
|
...card('support', 0, 0),
|
|
special: { durationSeconds: 3, scoreUpPercent: 0, activationRateUpPercent: 50 }
|
|
};
|
|
const result = estimateScoreAchievementProbabilities(
|
|
[main, support],
|
|
{ main: 0, support: 0 },
|
|
chart([1.1], [null, 0]),
|
|
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 4 }
|
|
);
|
|
assert.equal(result.maximum, 0.75);
|
|
});
|
|
|
|
test('블루 보드와 스페셜의 발동률 증가 배율을 합산한다', () => {
|
|
const main = card('main', 100, 55);
|
|
main.active.activationRateUpPercent = 30;
|
|
const support: OptimizerCard = {
|
|
...card('support', 0, 0),
|
|
special: { durationSeconds: 3, scoreUpPercent: 0, activationRateUpPercent: 40 }
|
|
};
|
|
const result = estimateScoreAchievementProbabilities(
|
|
[main, support],
|
|
{ main: 0, support: 0 },
|
|
chart([1.1], [null, 0]),
|
|
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 6 }
|
|
);
|
|
assert.ok(Math.abs(result.maximum - 0.935) < 1e-12);
|
|
});
|
|
|
|
test('확정 발동이면 모든 달성 확률이 100%다', () => {
|
|
const result = estimateScoreAchievementProbabilities(
|
|
[card('main', 100, 100)],
|
|
{ main: 0 },
|
|
chart([1.1, 2.1, 3.1]),
|
|
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 5 }
|
|
);
|
|
assert.deepEqual(result, {
|
|
maximum: 1,
|
|
atLeast995Percent: 1,
|
|
atLeast99Percent: 1,
|
|
atLeast95Percent: 1,
|
|
samples: 1_000
|
|
});
|
|
});
|
|
|
|
test('시간별 최고점 확률은 첫 영향 노트에서 감소하고 같은 발동을 중복 계산하지 않는다', () => {
|
|
const cards = [card('main', 100, 50, 0.9), card('weak', 20, 10)];
|
|
const source = chart([0.5, 1.1, 1.5, 1.95, 2.1, 2.1]);
|
|
const options = { perfectNoteScore: 100, sampleCount: 1 };
|
|
const probabilities = calculateMaximumScoreProbabilityTimeline(cards, {}, source, options);
|
|
// The weak skill alone raises the maximum at 1.95 seconds.
|
|
assert.deepEqual(probabilities, [1, 0.5, 0.5, 0.05, 0.025, 0.025]);
|
|
assert.equal(probabilities.at(-1), estimateScoreAchievementProbabilities(cards, {}, source, options).maximum);
|
|
assert.deepEqual(calculateMaximumScoreProbabilityTimeline(cards, {}, chart([]), options), []);
|
|
});
|
|
|
|
test('시간별 확률은 겹친 동률 액티브와 이후 필수 발동 사이의 의존성을 반영한다', () => {
|
|
const cards = [card('long', 100, 50, 0.9), card('short', 100, 50, 0.3)];
|
|
assert.deepEqual(calculateMaximumScoreProbabilityTimeline(cards, {}, chart([1.1, 1.5, 2.1]), {
|
|
perfectNoteScore: 100
|
|
}), [0.75, 0.5, 0.375]);
|
|
});
|
|
|
|
test('미발동 토글 시 현재 최고점 기준으로 확률을 계산하고 반올림으로 점수가 같으면 실패를 허용한다', () => {
|
|
const cards = [card('strong', 100, 50), card('weak', 20, 10)];
|
|
assert.deepEqual(calculateMaximumScoreProbabilityTimeline(cards, {}, chart([1.1, 2.1]), {
|
|
perfectNoteScore: 100,
|
|
excludedActiveWindows: [{ cardId: 'strong', startSeconds: 1 }]
|
|
}), [0.1, 0.05]);
|
|
const options = { perfectNoteScore: 0.1, sampleCount: 1 };
|
|
assert.deepEqual(calculateMaximumScoreProbabilityTimeline(cards, {}, chart([1.1]), options), [1]);
|
|
assert.equal(estimateScoreAchievementProbabilities(cards, {}, chart([1.1]), options).maximum, 1);
|
|
});
|
|
|
|
|
|
test('99.5% 경계 점수를 포함하고 99% 이상 점수와 구분한다', () => {
|
|
const estimate = (fallbackBoost: number) => estimateScoreAchievementProbabilities(
|
|
[card('strong', 100, 50), card('fallback', fallbackBoost, 100)],
|
|
{ strong: 0, fallback: 0 },
|
|
chart([1.1]),
|
|
{ perfectNoteScore: 10_000, sampleCount: 25_000, seed: 7 }
|
|
);
|
|
// 최고점 20,000점에서 19,900점은 포함하고 19,850점은 제외한다.
|
|
assert.equal(estimate(99).atLeast995Percent, 1);
|
|
const below = estimate(98.5);
|
|
assert.ok(Math.abs(below.atLeast995Percent - 0.5) < 0.02);
|
|
assert.equal(below.atLeast99Percent, 1);
|
|
});
|