fix: correct chart scoring weights for v1.0.3

This commit is contained in:
2026-08-19 16:57:54 +09:00
parent e259f82b69
commit bd9d5a6ba6
374 changed files with 1551 additions and 1485 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "holodream-tools", "name": "holodream-tools",
"version": "1.0.2", "version": "1.0.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "holodream-tools", "name": "holodream-tools",
"version": "1.0.2", "version": "1.0.3",
"engines": { "engines": {
"node": ">=22.12.0" "node": ">=22.12.0"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "holodream-tools", "name": "holodream-tools",
"version": "1.0.2", "version": "1.0.3",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+19
View File
@@ -0,0 +1,19 @@
# 홀로도리 계산기 1.0.3
악곡 채보의 노트 종류와 점수 가중치를 실제 동작에 맞게 수정한 업데이트입니다.
## 주요 변경 사항
- 롱노트 내부의 모든 판정을 표시 형태와 관계없이 0.1배로 계산하도록 수정했습니다.
- 같은 시각에 존재하는 여러 롱노트 판정을 각각 점수에 반영하도록 수정했습니다.
- 롱노트 끝에 연결된 플릭은 1.0배, 독립 플릭은 1.05배로 구분하도록 수정했습니다.
- 전체 Hard/Expert 채보 데이터를 새로운 판정 규칙으로 다시 생성했습니다.
- Our Promise Expert의 시간대별 노트 가중치와 총 가중치가 비교 대상 계산기와 일치하는 것을 확인했습니다.
## 참고 사항
노트 종류별 가중치 오류가 수정되어 이전 버전보다 이론상 고점과 달성 확률이 낮아질 수 있습니다. 아직 확인되지 않은 게임 내부 계산 방식이나 패시브 적용 규칙에 따라 실제 플레이 결과와 차이가 남을 수 있으므로, 결과는 조합 간 비교와 참고값으로 함께 활용해 주세요.
## 다운로드
Release의 Assets에서 `HolodoriCalc-offline-v1.0.3.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
+68 -6
View File
@@ -79,12 +79,56 @@ function extractJsonParseArgument(moduleText) {
function flattenEvents(chartData) { function flattenEvents(chartData) {
const noteEvents = chartData.note_events ?? {}; const noteEvents = chartData.note_events ?? {};
const green = (noteEvents.green_blocks ?? noteEvents.green ?? []) const greenBlocks = (noteEvents.green_blocks ?? noteEvents.green ?? [])
.flatMap((block) => Array.isArray(block) ? block : [block]); .map((rawBlock) => Array.isArray(rawBlock) ? rawBlock : [rawBlock]);
const pink = noteEvents.pink ?? [];
const internalGreenKeys = new Set(greenBlocks.flatMap((block) => block.slice(1, -1)
.map((note) => `${note.time_seconds}|${note.lane}`)));
const beatGap = (end, flick) => flick.beat - end.beat;
const sameShape = (end, flick) =>
end.lane_center === flick.lane_center && end.lane_half_width === flick.lane_half_width;
const shapeOverlapRatio = (end, flick) => {
const left = Math.max(end.lane_center - end.lane_half_width, flick.lane_center - flick.lane_half_width);
const right = Math.min(end.lane_center + end.lane_half_width, flick.lane_center + flick.lane_half_width);
const unionLeft = Math.min(end.lane_center - end.lane_half_width, flick.lane_center - flick.lane_half_width);
const unionRight = Math.max(end.lane_center + end.lane_half_width, flick.lane_center + flick.lane_half_width);
return Math.max(0, right - left) / Math.max(Number.EPSILON, unionRight - unionLeft);
};
const isDirectContinuation = (end, flick) =>
beatGap(end, flick) > 0 && beatGap(end, flick) <= 0.25 + 1e-9 && sameShape(end, flick);
const continuesIntoFlick = (note) => pink.some((flick) => isDirectContinuation(note, flick));
// 롱노트 끝 플릭은 색상이 아니라 직전 green block과의 연결 관계로 구분한다.
// 끝이 mid인 블록은 다음 플릭이 끝 판정을 대신하며, 아주 짧은 동일 형상 연결도 같은 경우다.
const isHoldEndFlick = (flick) => greenBlocks.some((block) => {
const end = block.at(-1);
if (!end) return false;
const gap = beatGap(end, flick);
return isDirectContinuation(end, flick) ||
(end.note_type === 'mid' && gap > 0 && gap <= 0.5 + 1e-9 && shapeOverlapRatio(end, flick) >= 0.8);
});
const green = greenBlocks
.flatMap((rawBlock) => {
const block = Array.isArray(rawBlock) ? rawBlock : [rawBlock];
return block.map((note, index) => ({
...note,
// 롱노트 안쪽 판정은 점 모양이어도 0.1배다. 시작·끝의 일반 판정만 1배다.
type: (index === 0 || index === block.length - 1) &&
note.note_type !== 'mid' &&
!internalGreenKeys.has(`${note.time_seconds}|${note.lane}`) &&
!(index === block.length - 1 && continuesIntoFlick(note))
? 'regular'
: 'mid'
}));
});
return [ return [
...(noteEvents.blue ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular' })), ...(noteEvents.blue ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
...green.map((note) => ({ ...note, type: note.note_type ?? 'regular' })), ...green,
...(noteEvents.pink ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular', is_flick: true })) // 롱노트 끝에 붙은 플릭은 판정 모양과 달리 1.0배, 독립 플릭만 1.05배다.
...pink.map((note) => ({
...note,
type: note.note_type ?? 'regular',
is_flick: !isHoldEndFlick(note)
}))
] ]
.map((note) => ({ .map((note) => ({
timeSeconds: note.time_seconds, timeSeconds: note.time_seconds,
@@ -95,7 +139,23 @@ function flattenEvents(chartData) {
.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1)); .sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
} }
function scoreRatioFromNotes(notes) { function legacyCalibrationNotes(chartData) {
const noteEvents = chartData.note_events ?? {};
const green = (noteEvents.green_blocks ?? noteEvents.green ?? [])
.flatMap((block) => Array.isArray(block) ? block : [block]);
return [
...(noteEvents.blue ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
...green.map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
...(noteEvents.pink ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular', is_flick: true }))
].map((note) => ({
timeSeconds: note.time_seconds,
type: note.type === 'mid' ? 'mid' : 'regular',
isFlick: note.is_flick === true
}));
}
function calibratedScoreRatio(chartData) {
const notes = legacyCalibrationNotes(chartData);
const countedMidTimes = new Set(); const countedMidTimes = new Set();
let weight = 0; let weight = 0;
for (const note of notes) { for (const note of notes) {
@@ -156,7 +216,9 @@ async function worker() {
songId: item.songId, songId: item.songId,
difficulty: item.difficulty.toLowerCase(), difficulty: item.difficulty.toLowerCase(),
durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0), durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0),
scoreRatio: scoreRatioFromNotes(notes), // 실측으로 보정된 1노트 기본점수 분모는 기존 채보 가중치 모델을 유지한다.
// 실제 점수 타임라인의 롱노트 분류와는 별개의 보정값이다.
scoreRatio: calibratedScoreRatio(chartData),
scoreCalibrationSource: 'measured-formula-v1', scoreCalibrationSource: 'measured-formula-v1',
notes, notes,
specialStartSeconds: (chartData.sp_activation_events ?? []) specialStartSeconds: (chartData.sp_activation_events ?? [])
File diff suppressed because it is too large Load Diff
+1 -7
View File
@@ -54,13 +54,7 @@ function finalNoteScore(score: number, percent: number): number {
} }
function scoreNotes(chart: ExactSongChart): ExactSongChart['notes'] { function scoreNotes(chart: ExactSongChart): ExactSongChart['notes'] {
const countedMidTimes = new Set<number>(); return chart.notes;
return chart.notes.filter((note) => {
if (note.type !== 'mid') return true;
if (countedMidTimes.has(note.timeSeconds)) return false;
countedMidTimes.add(note.timeSeconds);
return true;
});
} }
function prepareModel( function prepareModel(
+5 -5
View File
@@ -20,14 +20,14 @@ test('종합력과 악곡 비율로 PERFECT 1노트 기본점수를 계산한다
assert.equal(perfectNoteScoreFromPower(0, 200, 777), 777); assert.equal(perfectNoteScoreFromPower(0, 200, 777), 777);
}); });
test('플릭 1.05배와 같은 시각 홀드 중간 판정의 단일 가중치로 환산비율을 계산한다', () => { test('플릭 1.05배와 홀드 중간 판정 각각의 가중치로 환산비율을 계산한다', () => {
const ratio = scoreRatioFromNotes([ const ratio = scoreRatioFromNotes([
{ timeSeconds: 1, type: 'regular', isFlick: false }, { timeSeconds: 1, type: 'regular', isFlick: false },
{ timeSeconds: 2, type: 'regular', isFlick: true }, { timeSeconds: 2, type: 'regular', isFlick: true },
{ timeSeconds: 3, type: 'mid', isFlick: false }, { timeSeconds: 3, type: 'mid', isFlick: false },
{ timeSeconds: 3, type: 'mid', isFlick: false } { timeSeconds: 3, type: 'mid', isFlick: false }
]); ]);
assert.ok(Math.abs(ratio - 2.15 * 0.3713416563352296) < 1e-12); assert.ok(Math.abs(ratio - 2.25 * 0.3713416563352296) < 1e-12);
}); });
test('기본점수의 소수점을 유지하고 가창 보너스 적용 후 노트별로 올림한다', () => { test('기본점수의 소수점을 유지하고 가창 보너스 적용 후 노트별로 올림한다', () => {
@@ -51,13 +51,13 @@ test('홀드 중간 노트는 10%, 플릭은 1.05배로 계산한다', () => {
assert.equal(result.baselineScore, 60 + 630); assert.equal(result.baselineScore, 60 + 630);
}); });
test('같은 시각에 중복된 홀드 중간 판정은 한 번만 계산한다', () => { test('같은 시각의 여러 홀드 중간 판정을 각각 계산한다', () => {
const result = calculateExactScore([inactiveCard], {}, chart([ const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'mid', isFlick: false }, { timeSeconds: 1, type: 'mid', isFlick: false },
{ timeSeconds: 1, type: 'mid', isFlick: false } { timeSeconds: 1, type: 'mid', isFlick: false }
]), { perfectNoteScore: 600 }); ]), { perfectNoteScore: 600 });
assert.equal(result.baselineScore, 60); assert.equal(result.baselineScore, 120);
assert.equal(result.notes.length, 1); assert.equal(result.notes.length, 2);
}); });
test('액티브 발동 확률과 스페셜 스코어 서포트를 기대 점수에 반영한다', () => { test('액티브 발동 확률과 스페셜 스코어 서포트를 기대 점수에 반영한다', () => {
+1 -10
View File
@@ -18,13 +18,10 @@ type ActiveCandidate = {
export const CHART_SCORE_WEIGHT_COEFFICIENT = 0.3713416563352296; export const CHART_SCORE_WEIGHT_COEFFICIENT = 0.3713416563352296;
export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number { export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number {
const countedMidTimes = new Set<number>();
let weight = 0; let weight = 0;
for (const note of notes) { for (const note of notes) {
if (note.type === 'mid') { if (note.type === 'mid') {
if (countedMidTimes.has(note.timeSeconds)) continue;
countedMidTimes.add(note.timeSeconds);
weight += 0.1; weight += 0.1;
continue; continue;
} }
@@ -129,13 +126,7 @@ export function calculateExactScore(
options: ExactScoreOptions options: ExactScoreOptions
): ExactScoreResult { ): ExactScoreResult {
const directBonus = options.directSongScoreBonusPercent ?? 0; const directBonus = options.directSongScoreBonusPercent ?? 0;
const countedMidTimes = new Set<number>(); const scoreNotes = chart.notes;
const scoreNotes = chart.notes.filter((note) => {
if (note.type !== 'mid') return true;
if (countedMidTimes.has(note.timeSeconds)) return false;
countedMidTimes.add(note.timeSeconds);
return true;
});
const notePoints = scoreNotes.map((note, index) => { const notePoints = scoreNotes.map((note, index) => {
const combo = index + 1; const combo = index + 1;
const baseNoteScore = note.type === 'mid' const baseNoteScore = note.type === 'mid'
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More