Files
HolodoriCalc/src/lib/engine/score.test.ts
T
dal4segno 4b8313c8a8
Publish GitHub Pages output / publish (push) Successful in 41s
fix: apply event song bonus to final score
2026-10-04 02:54:02 +09:00

179 lines
8.7 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import scoreCalibrations from '../../../data/score-calibrations.json' with { type: 'json' };
import { buildExactSkillWindows, calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
const inactiveCard: OptimizerCard = {
id: 'card',
name: '카드',
active: { cooldownSeconds: 999, durationSeconds: 1, scoreUpPercent: 100, probabilityPercent: 100 },
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
function chart(notes: ExactSongChart['notes']): ExactSongChart {
return { songId: 'test', difficulty: 'expert', durationSeconds: 120, scoreRatio: 100, notes, specialStartSeconds: [] };
}
test('종합력과 악곡 비율로 PERFECT 1노트 기본점수를 계산한다', () => {
assert.equal(perfectNoteScoreFromPower(120_000, 200), 600);
assert.equal(perfectNoteScoreFromPower(120_100, 200), 600.5);
assert.equal(perfectNoteScoreFromPower(0, 200, 777), 777);
});
test('플릭 1.05배와 홀드 중간 판정 각각의 가중치로 환산비율을 계산한다', () => {
const ratio = scoreRatioFromNotes([
{ timeSeconds: 1, type: 'regular', isFlick: false },
{ timeSeconds: 2, type: 'regular', isFlick: true },
{ timeSeconds: 3, type: 'mid', isFlick: false },
{ timeSeconds: 3, type: 'mid', isFlick: false }
]);
assert.ok(Math.abs(ratio - 2.25 * 0.3713416563352296) < 1e-12);
});
test('기본점수의 소수점을 유지하고 가창 보너스 적용 후 노트별로 올림한다', () => {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'regular', isFlick: false }
]), { perfectNoteScore: 1_354.507, directSongScoreBonusPercent: 10 });
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 });
assert.equal(result.baselineScore, 99 * 600 + 606);
});
test('홀드 중간 노트는 10%, 플릭은 1.05배로 계산한다', () => {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'mid', isFlick: false },
{ timeSeconds: 2, type: 'regular', isFlick: true }
]), { perfectNoteScore: 600 });
assert.equal(result.baselineScore, 60 + 630);
});
test('같은 시각의 여러 홀드 중간 판정을 각각 계산한다', () => {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'mid', isFlick: false },
{ timeSeconds: 1, type: 'mid', isFlick: false }
]), { perfectNoteScore: 600 });
assert.equal(result.baselineScore, 120);
assert.equal(result.notes.length, 2);
});
test('액티브 발동 확률과 스페셜 스코어 서포트를 기대 점수에 반영한다', () => {
const card: OptimizerCard = {
id: 'score',
name: '스코어 카드',
active: { cooldownSeconds: 1, durationSeconds: 1, scoreUpPercent: 100, probabilityPercent: 50 },
special: { durationSeconds: 2, scoreUpPercent: 100 }
};
const result = calculateExactScore([card], {}, {
...chart([{ timeSeconds: 1, type: 'regular', isFlick: false }]),
specialStartSeconds: [0]
}, { perfectNoteScore: 600 });
assert.equal(result.expectedScore, 1_200);
assert.equal(result.theoreticalMaxScore, 1_800);
});
test('블루 보드와 스페셜의 발동률 증가 배율을 합산해 기대 점수에 반영한다', () => {
const main: OptimizerCard = {
id: 'main',
name: '메인',
active: {
cooldownSeconds: 1,
durationSeconds: 1,
scoreUpPercent: 100,
probabilityPercent: 55,
activationRateUpPercent: 30
},
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
const support: OptimizerCard = {
id: 'support',
name: '서포트',
active: { cooldownSeconds: 999, durationSeconds: 0, scoreUpPercent: 0, probabilityPercent: 0 },
special: { durationSeconds: 3, scoreUpPercent: 0, activationRateUpPercent: 40 }
};
const result = calculateExactScore([main, support], {}, {
...chart([{ timeSeconds: 1, type: 'regular', isFlick: false }]),
specialStartSeconds: [null, 0]
}, { perfectNoteScore: 100 });
assert.equal(result.expectedScore, 194);
assert.equal(result.theoreticalMaxScore, 200);
});
test('등록된 실측 ratio가 모든 1노트 측정값을 재현한다', () => {
for (const calibration of scoreCalibrations.charts) {
for (const measurement of calibration.measurements) {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'regular', isFlick: false }
]), {
perfectNoteScore: measurement.overallPower / calibration.scoreRatio,
directSongScoreBonusPercent: measurement.directSongScoreBonusPercent
});
assert.equal(
result.baselineScore,
measurement.normalPerfectScore,
`${calibration.songId} ${calibration.difficulty}`
);
}
}
});
test('지정한 액티브만 실패 처리하고 겹친 다른 카드와 다음 발동은 유지한다', () => {
const strong: OptimizerCard = {
...inactiveCard, id: 'strong',
active: { cooldownSeconds: 10, durationSeconds: 3, scoreUpPercent: 100, probabilityPercent: 50 }
};
const weak: OptimizerCard = {
...inactiveCard, id: 'weak',
active: { cooldownSeconds: 10, durationSeconds: 3, scoreUpPercent: 50, probabilityPercent: 100 }
};
const interval = 10 / 1.04;
const song = chart([interval - 0.01, interval, interval + 3, interval * 2].map((timeSeconds) => ({ timeSeconds, type: 'regular' as const, isFlick: false })));
const reductions = { strong: 4 as const, weak: 4 as const };
const cards = [strong, weak];
const original = calculateExactScore(cards, reductions, song, { perfectNoteScore: 100 });
const excludedActiveWindows = [{ cardId: 'strong', startSeconds: interval }];
const modified = calculateExactScore(cards, reductions, song, { perfectNoteScore: 100, excludedActiveWindows });
assert.deepEqual(modified.notes.map((note) => note.theoreticalMaxScore), [100, 150, 100, 200]);
assert.deepEqual(modified.notes.map((note) => note.expectedScore), [100, 150, 100, 175]);
assert.equal(modified.baselineScore, original.baselineScore);
assert.equal(modified.scoreBySecond[9], 250);
assert.deepEqual(calculateExactScore(cards, reductions, song, { perfectNoteScore: 100, excludedActiveWindows: [] }), original);
const windows = buildExactSkillWindows(cards, reductions, song, excludedActiveWindows);
assert.equal(windows.effectiveActiveWindows.find((window) => window.startSeconds === interval)?.cardId, 'weak');
assert.equal(windows.effectiveActiveWindows.find((window) => window.startSeconds === interval * 2)?.cardId, 'strong');
assert.ok(windows.activeWindows.some((window) => window.cardId === 'strong' && window.startSeconds === interval));
const bothExcluded = [...excludedActiveWindows, { cardId: 'weak', startSeconds: interval }];
const noActive = calculateExactScore(cards, reductions, song, { perfectNoteScore: 100, excludedActiveWindows: bothExcluded });
assert.equal(noActive.notes[1].theoreticalMaxScore, noActive.notes[1].baselineScore);
assert.ok(!buildExactSkillWindows(cards, reductions, song, bothExcluded).effectiveActiveWindows.some((window) => window.startSeconds === interval));
});