fix: calculate chart scores with song formulas and hidden note weights
Publish GitHub Pages output / publish (push) Successful in 54s
Publish GitHub Pages output / publish (push) Successful in 54s
This commit is contained in:
+5
-1
@@ -1,9 +1,13 @@
|
||||
---
|
||||
updatedAt: 2026-09-06T20:54:00+09:00
|
||||
updatedAt: 2026-09-06T21:34:36+09:00
|
||||
---
|
||||
|
||||
## 2026년 9월 6일
|
||||
|
||||
### 악곡별 점수 계산 정확도 개선
|
||||
|
||||
`IMPROVED` 388개 채보의 기본 점수를 악곡·난이도별 공식으로 계산하도록 개선했습니다. 비가시 채보 지점을 포함한 점수 환산 기준을 반영했으며, 배치 분석과 조합 탐색에서 적용 여부를 ‘곡별 공식’으로 표시합니다. 코로네 곡 Expert도 원본 채보의 비가시 지점을 직접 집계해 반영했습니다.
|
||||
|
||||
### 배치 분석 이용 통계 안내
|
||||
|
||||
`IMPROVED` 통계 수집에 동의하면 배치 분석 실행 시 선택한 카드·개화 단계·악곡·난이도를 서비스 개선을 위해 수집합니다. 수집 범위 변경에 따라 기존 이용자에게도 다시 동의를 요청하며, 거부해도 모든 기능을 이용할 수 있습니다.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 코로네 Expert 원본 SUS 분모 계산
|
||||
|
||||
2026-09-06, 공개 에셋 카탈로그 revision 70에서 `chart_m0341_expert.sus`를 확보했다. SHA-256은 `b6523da612971b3c8816a8fbb5d86d14d569b259aae84387f2818dd31944d969`이며 서비스 채보의 원본 해시와 일치한다.
|
||||
|
||||
## 집계
|
||||
|
||||
| 종류 | 개수 | 1개 가중치 | 합 |
|
||||
|---|---:|---:|---:|
|
||||
| 일반 | 464 | 1000 | 464000 |
|
||||
| 독립 플릭 | 85 | 1050 | 89250 |
|
||||
| 홀드 시작 | 109 | 1000 | 109000 |
|
||||
| 홀드 끝 | 83 | 1000 | 83000 |
|
||||
| 홀드 끝 플릭 | 26 | 1000 | 26000 |
|
||||
| 보이는 릴레이 | 40 | 100 | 4000 |
|
||||
| 홀드 중간 판정 | 151 | 100 | 15100 |
|
||||
| 비가시 릴레이 | 105 | 100 | 10500 |
|
||||
| 베지어 제어점 | 0 | 100 | 0 |
|
||||
| **전체 분모** | | | **800850** |
|
||||
|
||||
득점 판정 958개의 합은 790350이다. 비가시 릴레이 105개를 분모에만 더한다. 일반 노트=1 단위로는 **800.85**다.
|
||||
|
||||
공개 [SUS 파서](https://github.com/UntitledCharts/sonolus-level-converters/blob/main/sonolus_converters/holodori_sus/loader.py)의 종류 구분과 압축 토큰 규칙을 대조했다. family 3의 kind 4/5를 베지어/비가시 릴레이로 집계한다. `0xNN`은 base62 빈 칸 압축이며 노트가 아니다. 같은 채널이 여러 줄로 나와도 모두 유지한다. 생성되는 홀드 중간 판정은 SUS 헤더의 카테고리별 개수를 사용한다. 따라서 이 도구는 전체 득점 타임라인을 복원하는 파서가 아니다.
|
||||
|
||||
## 검증
|
||||
|
||||
- 기존 실측 대상 18개 채보 + 코로네 Easy/Normal/Hard = **알려진 분모 21개 전부 일치**.
|
||||
- 코로네 Hard: 득점 합 495550 + 비가시 48×100 = **500350**.
|
||||
- 실제 엔진의 기존 일반 PERFECT 실측 19건은 계속 모두 일치한다.
|
||||
- 이번 Expert 분모는 원본 구조와 검증된 집계법으로 산출한 값이다. 코로네 Expert의 신규 게임 실측값은 아직 없다.
|
||||
|
||||
SUS 헤더의 `SCORE_LEVEL`은 26이지만, 현재 공개 마스터 및 서비스 난이도는 **28**이다. 환산식에는 28을 사용한다. 곡별 계수는 5다.
|
||||
|
||||
```text
|
||||
곡·난이도 배율 = 2.3 × (1 + 5/1000 × (28 − 5)) = 2.5645
|
||||
scoreRatio = 800.85 / 2.5645 = 312.28309612010145
|
||||
일반 PERFECT 기본점수 = 종합력 / 312.28309612010145
|
||||
```
|
||||
|
||||
## 적용과 재현
|
||||
|
||||
신규 값은 외부 연구 메타데이터와 구분하여 `sus-score-formulas.json`에 출처·해시·집계표·검증 대상과 함께 기록했다. 공식 적용 및 채보 수집은 이 값을 함께 읽고, SUS 해시가 달라지면 적용을 거부한다. 현재 서비스 카탈로그 **388개 모두 formula-v1**이며 추정값으로 남아 있는 채보는 없다. 신규 곡의 분모가 자동으로 확보되는 것은 아니며 원본 확보·집계·검증 과정이 필요하다.
|
||||
|
||||
```sh
|
||||
node scripts/calculate-sus-score-formula.mjs data/raw/sus/chart_m0341_expert.sus
|
||||
node scripts/validate-sus-score-formulas.mjs data/raw/sus
|
||||
npm run test:sus-score
|
||||
```
|
||||
|
||||
원본 SUS는 `data/raw/sus`에 저장되어 있으며 Git 및 공개 배포에는 포함하지 않는다.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
||||
# 곡별 점수 환산식 검증 — 2026-09-06
|
||||
|
||||
## 결론
|
||||
|
||||
최신 후속 결과: 원본 SUS로 m0341 Expert 분모 800850을 산출하여 적용했다. 현재 **388개 전체가 공식 기반**이다. 기존 분모 21개와의 교차 검증 및 산출 과정은 [코로네 Expert SUS 분석](m0341-expert-sus.md)에 기록했다. 아래 387개 적용 및 누락 관련 설명은 이전 검증 단계의 이력이다.
|
||||
|
||||
후속 구현: 387개 채보에 `formula-v1`을 적용했다. `m0341.expert`는 기존 추정값을 유지한다. 아래 분석 수치와 비교표는 적용 전 상태의 기록이며, 검증 스크립트를 현재 상태에서 실행하면 공식 적용 채보의 모델 간 차이는 0으로 표시된다. 채보 수집 시에도 공식이 우선되며, 실측 적용 명령은 공식 기반 값을 덮어쓰지 않는다.
|
||||
|
||||
연구 공식에 곡별 계수·난이도와 `perfect_note_weight`를 적용하면 저장된 **13곡, 18개 곡/난이도, 19건의 일반 PERFECT 실측값을 모두 정확히 재현**한다. 실측값에 맞춘 새로운 상수 피팅은 하지 않았다. 기존 공통 상수보다 근거가 강하며, 곡별 환산값을 생성할 공식으로 채택할 수 있다.
|
||||
|
||||
단, 서비스의 `notes` 가중치 합을 분모로 그대로 쓰면 안 된다. 비가시 지점을 포함한 환산용 분모는 `scoreFormula.perfectNoteWeight`로 별도 보관한다. 득점 노트 및 스페셜 시각과 이벤트 계산은 변경하지 않았다.
|
||||
|
||||
## 자료와 재현 방법
|
||||
|
||||
- 연구 문서: https://docs.google.com/document/d/1P1newxmgxS8b-0raHk5vqnbb8X1nXh1xs3d4gYuhUMQ
|
||||
- 문서가 제공하는 메타데이터: https://airei.dev/metadata/music_meta.json
|
||||
- 메타데이터는 이번 조회에서 HTTP 200으로 접근 가능했다. 원문 775개 레코드 중 서비스 카탈로그 및 실측표에 필요한 389개 레코드의 관련 필드만 `song-score-formula-reference.json`에 보존했다. 조회 시각·원본 전체 SHA-256도 포함했다.
|
||||
- 실측 원본: `data/score-calibrations.json`. 이 자료는 곡/난이도, 종합력, 가창 보너스, 일반 PERFECT 점수를 제공한다. 플랫폼·전체 플레이 로그·측정 시각은 기록되어 있지 않다.
|
||||
- 로컬 HolodoriDB 한국어 원본 `origin/main`: `1d7c7850bf2df34239c440b5b0cc53bbd72e5d5d`.
|
||||
- `Music.json`의 곡별 계수 및 `MusicDifficulty.json`의 난이도 레벨은 참고 데이터 389개 모두 일치했다. `Setting.json`의 `liveDeckEvaluationCoefficientPermilMultiply`는 2300이다.
|
||||
|
||||
실행:
|
||||
|
||||
```sh
|
||||
node scripts/validate-song-score-formula.mjs
|
||||
```
|
||||
|
||||
스크립트는 고정 참고 데이터와 현재 저장소를 읽기만 한다. 실측 점수는 BigInt 유리수 연산으로 마지막 올림까지 검증하며, 기존 `scoreRatio`는 공식 입력으로 사용하지 않는다. 누락된 실측 참고값, 실측 불일치, 카탈로그 난이도 또는 콤보 수 불일치에서는 실패한다. 실측이 없는 카탈로그의 참고 데이터 누락은 별도로 보고한다.
|
||||
|
||||
## 검증 공식
|
||||
|
||||
`W`는 일반 PERFECT=1000 단위의 `perfect_note_weight`, `k`는 곡별 `liveScoreCoefficientPermil`, `L`은 난이도 레벨이다.
|
||||
|
||||
```text
|
||||
C = 2.3 × (1 + k/1000 × (L − 5))
|
||||
scoreRatio = (W/1000) / C
|
||||
일반 PERFECT 점수 = ceil(종합력 / scoreRatio × (1 + 가창 보너스/100))
|
||||
```
|
||||
|
||||
실측 조건은 콤보·스킬 배율이 없는 일반 PERFECT 기본 점수로 해석한다. 기존 실측 검산도 같은 전제를 사용한다.
|
||||
|
||||
올림된 점수 S로부터 ratio는 단일 값으로 결정되지 않는다. A=종합력×가창배율이면 가능한 구간은 `[A/S, A/(S−1))`다. 모든 실측에서 연구식의 ratio가 이 구간 안에 들었으며, 같은 채보에 여러 측정이 있으면 모두 충족했다. 기존 보정 ratio와 연구 ratio가 소수점까지 같을 필요는 없다.
|
||||
|
||||
## 전체 실측 결과
|
||||
|
||||
| 곡 | 난이도 | 실측 점수 | 연구식 |
|
||||
|---|---|---:|---:|
|
||||
| Our Promise | Easy | 6,571 | 6,571 |
|
||||
| Our Promise | Normal | 4,091 | 4,091 |
|
||||
| Our Promise | Hard | 2,295 | 2,295 |
|
||||
| Our Promise | Expert | 1,455 | 1,455 |
|
||||
| PSYCHO | Hard | 2,023 | 2,023 |
|
||||
| PSYCHO | Expert, 가창 10% | 1,203 | 1,203 |
|
||||
| PSYCHO | Expert, 가창 8.26% | 1,184 | 1,184 |
|
||||
| Los! Los! Los! | Hard | 1,633 | 1,633 |
|
||||
| Los! Los! Los! | Expert | 1,008 | 1,008 |
|
||||
| Erandekurete Arigatou | Expert | 1,742 | 1,742 |
|
||||
| Bling-Bang-Bang-Born | Expert | 998 | 998 |
|
||||
| ALiCE&u | Expert | 980 | 980 |
|
||||
| Ahoy!! We are Houshou Pirates | Expert | 1,128 | 1,128 |
|
||||
| Alien Alien | Expert | 761 | 761 |
|
||||
| Asuenokyokaisen | Expert | 1,014 | 1,014 |
|
||||
| Boom! Boom! Tropica Vacation | Expert | 937 | 937 |
|
||||
| UNDEAD | Expert | 1,113 | 1,113 |
|
||||
| Zenryoku-Jumping! | Expert | 1,671 | 1,671 |
|
||||
| NAKAMAKA | Expert | 841 | 841 |
|
||||
|
||||
## 원인: 점수를 받는 노트와 환산 분모의 차이
|
||||
|
||||
아래는 일반 노트=1로 정규화한 값이다.
|
||||
|
||||
| 채보 | 현재 notes 가중치 합 | 참고 playable 가중치 (`base_weight`) | 참고 환산 분모 (`perfect_note_weight`) |
|
||||
|---|---:|---:|---:|
|
||||
| Our Promise Expert | 632.05 | 632.05 | 633.95 |
|
||||
| PSYCHO Expert | 766.25 | 763.55 | 786.55 |
|
||||
| Boom! Boom! Tropica Vacation Expert | 801.75 | 798.55 | 839.95 |
|
||||
|
||||
Our Promise는 playable 집계가 같아도 분모가 1.9 크다. 따라서 현재 노트 분류만 정리한다고 해결되지 않는다. 연구 문서는 비가시 릴레이·베지어 지점에도 분모 가중치를 부여한다고 설명하며, 참고 메타데이터의 `perfect_note_weight − base_weight`는 그 설명과 부합한다. 이번에는 SUS 원문을 독립 파싱해 비가시 지점 개수를 재구성하지는 않았다.
|
||||
|
||||
PSYCHO 등에는 현재 notes와 참고 playable 집계 사이에도 차이가 있다. 비가시 분모 누락 외에 홀드·릴레이·끝 플릭 분류의 차이가 추가로 존재한다. 이 차이의 개별 노트 원인은 이번 검증에서 확정하지 않았다. 실제 노트 점수 및 전체 곡 점수 정확성은 별도 점검이 필요하다.
|
||||
|
||||
예를 들어 Boom! Boom! Tropica Vacation Expert는 종합력 296,408, 가창 0%, k=7, L=27이다.
|
||||
|
||||
```text
|
||||
현재 notes 합을 분모로 사용: ceil(296408 × 2.6542 / 801.75) = 982
|
||||
참고 환산 분모 사용: ceil(296408 × 2.6542 / 839.95) = 937
|
||||
실측: 937
|
||||
```
|
||||
|
||||
환산 분모를 늘리기 위해 비가시 노트를 서비스의 득점·콤보 배열에 추가해서는 안 된다. 분모 전용 필드로 보관해야 한다.
|
||||
|
||||
## 서비스 전체 범위 비교
|
||||
|
||||
현재 카탈로그 388개 중 참고 데이터가 있는 것은 **387개**다. 이들 모두 난이도 레벨 및 로컬 노트 수와 참고 full-combo 수가 일치한다. 같은 노트 수가 같은 노트 분류·가중치를 보장하지는 않는다.
|
||||
|
||||
- 실측 보정 채보: 16개. 올림 전 기본 점수의 연구식 대비 평균 절대 차이 0.0432%, 최대 0.0772%. 위 실측 점수는 기존 보정과 연구식 모두 재현한다.
|
||||
- 공통 상수 추정 채보: 371개. 연구식 대비 평균 절대 차이 **3.9301%**. 절대 차이 5% 초과 114개, 10% 초과 15개.
|
||||
- KINGWORLD Expert: 현재 기본 점수가 연구식보다 **21.8420% 낮음**.
|
||||
- Excuse My Rudeness, But Could You Please RIP? Expert: 현재 기본 점수가 연구식보다 **13.9990% 높음**.
|
||||
- 참고 데이터 누락: **m0341.expert**. 현재 추정 채보 372개 중 하나다.
|
||||
|
||||
위 백분율은 `(현재 올림 전 기본 점수 / 연구식 기본 점수 − 1) × 100`이다. 실측하지 않은 채보의 수치는 두 모델 간 차이이며, 실제 게임 오차율로 검증한 값은 아니다. 카탈로그 포함 전체를 집계했으며 공개 시각에 따른 필터는 적용하지 않았다.
|
||||
|
||||
## 적용 시 경계와 남은 검증
|
||||
|
||||
1. 곡별 계수·난이도·독립적인 PERFECT 분모를 보존하고 공식으로 ratio를 생성한다. 서비스의 `종합력 / scoreRatio` 인터페이스는 유지 가능하다.
|
||||
2. 실측값은 회귀 검증 기준으로 유지한다. 현재 보정값과 연구 ratio의 작은 차이는 올림된 1노트 점수만으로는 우열을 판별할 수 없다.
|
||||
3. 참고 데이터가 없는 m0341 Expert는 분모를 별도 확보하거나 기존 추정과 구분하여 유지해야 한다.
|
||||
4. 실제 총점까지 검증됐다고 표시하지 않는다. 이번 증거는 일반 PERFECT 1노트 19건이며 스킬, 콤보, PC/모바일 차이, 홀드 중간 올림을 검증하지 않았다.
|
||||
5. 참고 메타데이터 제공자와 연구 문서는 같은 출처 계열이므로, 완전히 독립적인 두 연구의 교차 검증으로 보지 않는다. 독립적인 대조 기준은 저장소에 이미 있던 실측 점수다.
|
||||
|
||||
## 실행 확인
|
||||
|
||||
- 신규 검증 스크립트: 19/19 정확 일치.
|
||||
- 기존 `npm run data:validate-score-calibrations`: 18개 보정 항목 통과.
|
||||
- `npm run check`: 오류 0, 경고 0.
|
||||
- `npm run build`: 온라인 빌드 확인.
|
||||
|
||||
최초 검증 단계에서는 사용자 동작을 변경하지 않았다. 후속 공식 적용은 UPDATES.md에 기록했다. 기존 작업 중인 다른 변경은 보존했다.
|
||||
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"source": {
|
||||
"kind": "decrypted-sus",
|
||||
"catalogRevision": 70,
|
||||
"parser": "scripts/sus-score-weight.mjs"
|
||||
},
|
||||
"charts": [
|
||||
{
|
||||
"songId": "m0341",
|
||||
"fullComboNoteCount": 958,
|
||||
"counts": {
|
||||
"NORMAL": 464,
|
||||
"FLICK": 85,
|
||||
"LONG_START": 109,
|
||||
"LONG_END": 83,
|
||||
"LONG_FLICK_END": 26,
|
||||
"LONG_RELAY": 40,
|
||||
"LONG_CONTINUE": 151
|
||||
},
|
||||
"invisibleRelayCount": 105,
|
||||
"bezierPointCount": 0,
|
||||
"baseWeight": 790350,
|
||||
"perfectNoteWeight": 800850,
|
||||
"difficulty": "expert",
|
||||
"susSha256": "b6523da612971b3c8816a8fbb5d86d14d569b259aae84387f2818dd31944d969",
|
||||
"liveScoreCoefficientPermil": 5,
|
||||
"difficultyLevel": 28
|
||||
}
|
||||
],
|
||||
"validationCharts": [
|
||||
{
|
||||
"songId": "m0010",
|
||||
"difficulty": "expert",
|
||||
"susSha256": "3205deef6bee19b03fd745257421e5003cfd066b6464e70205675a6822b4fe58",
|
||||
"perfectNoteWeight": 638250
|
||||
},
|
||||
{
|
||||
"songId": "m0035",
|
||||
"difficulty": "expert",
|
||||
"susSha256": "bf9a6cf50046633e83e4a3a53113f388ea85ee0f332ed627a14671e2c3f75228",
|
||||
"perfectNoteWeight": 483100
|
||||
},
|
||||
{
|
||||
"songId": "m0040",
|
||||
"difficulty": "expert",
|
||||
"susSha256": "b2857491f68e385d437731e07f26be90a1e3ea19be3e43231b819de7daf3bb6c",
|
||||
"perfectNoteWeight": 709850
|
||||
},
|
||||
{
|
||||
"songId": "m0047",
|
||||
"difficulty": "expert",
|
||||
"susSha256": "db36ac771d525f867c155bb62df46a9bcbc33503f4e5a4b884661d02e6f10f43",
|
||||
"perfectNoteWeight": 731250
|
||||
},
|
||||
{
|
||||
"songId": "m0108",
|
||||
"difficulty": "expert",
|
||||
"susSha256": "cfc11a8411f49be4b3d89f386f8f9557bc6efaf8a56e36bc7b4eeae6891b841d",
|
||||
"perfectNoteWeight": 968500
|
||||
},
|
||||
{
|
||||
"songId": "m0121",
|
||||
"difficulty": "expert",
|
||||
"susSha256": "475f5236d936b13dca7898aac181e42ccb18901d23faf923b4363c4e747bbd6b",
|
||||
"perfectNoteWeight": 786550
|
||||
},
|
||||
{
|
||||
"songId": "m0121",
|
||||
"difficulty": "hard",
|
||||
"susSha256": "6d3a096b459ebf3543c8756686795d0e4515a7e4789f853ad2533071a617473c",
|
||||
"perfectNoteWeight": 457300
|
||||
},
|
||||
{
|
||||
"songId": "m0144",
|
||||
"difficulty": "expert",
|
||||
"susSha256": "6771f00103c6624021e462ccd1fac4ba56b5ea3dc10858d6b36ba82ad2d088a3",
|
||||
"perfectNoteWeight": 1054200
|
||||
},
|
||||
{
|
||||
"songId": "m0157",
|
||||
"difficulty": "easy",
|
||||
"susSha256": "0ce41e57bb03b05db9f8b6e2624464fd3adb112e03165630683b3072b1313ead",
|
||||
"perfectNoteWeight": 126900
|
||||
},
|
||||
{
|
||||
"songId": "m0157",
|
||||
"difficulty": "expert",
|
||||
"susSha256": "6584e49b665276de1df1f8ce69027d290b643f74484b4b643269b9663add45cf",
|
||||
"perfectNoteWeight": 633950
|
||||
},
|
||||
{
|
||||
"songId": "m0157",
|
||||
"difficulty": "hard",
|
||||
"susSha256": "2d6a7bff36c9561c7368b35f54033a625c2c208f44b0b72cf5e0786cfb9e19f2",
|
||||
"perfectNoteWeight": 385500
|
||||
},
|
||||
{
|
||||
"songId": "m0157",
|
||||
"difficulty": "normal",
|
||||
"susSha256": "b17cfb6933fbfdc58197ea6024003c6053446c1eafe9f900fd7512034731bfe7",
|
||||
"perfectNoteWeight": 210000
|
||||
},
|
||||
{
|
||||
"songId": "m0182",
|
||||
"difficulty": "expert",
|
||||
"susSha256": "144c7b3be2c2ff51e203ee697371929fa7748e921766b1a6f20ec66f2f9196cd",
|
||||
"perfectNoteWeight": 972400
|
||||
},
|
||||
{
|
||||
"songId": "m0182",
|
||||
"difficulty": "hard",
|
||||
"susSha256": "f73f824ed32165e60476ef56d7a5cfab30aecf21c2e5eab72425b9f30b2403d7",
|
||||
"perfectNoteWeight": 581150
|
||||
},
|
||||
{
|
||||
"songId": "m0201",
|
||||
"difficulty": "expert",
|
||||
"susSha256": "907ae19b907106d488365765f1787bdf0f117b16b3867feeb011d7593bf652f0",
|
||||
"perfectNoteWeight": 539400
|
||||
},
|
||||
{
|
||||
"songId": "m0309",
|
||||
"difficulty": "expert",
|
||||
"susSha256": "348869afc7110b14e2f84f702a787eee32d46dd34e1463961baac431a4623af8",
|
||||
"perfectNoteWeight": 839950
|
||||
},
|
||||
{
|
||||
"songId": "m0312",
|
||||
"difficulty": "expert",
|
||||
"susSha256": "3c57185b1315e440069d86c5f2df7c0679c34e0ef6be409f89ed22aa67d9cca8",
|
||||
"perfectNoteWeight": 758700
|
||||
},
|
||||
{
|
||||
"songId": "m0319",
|
||||
"difficulty": "expert",
|
||||
"susSha256": "d853bf70aeb3ad267df53a8f445788a2b22cd55ff07e6209d39bff0480e7f43b",
|
||||
"perfectNoteWeight": 906700
|
||||
},
|
||||
{
|
||||
"songId": "m0341",
|
||||
"difficulty": "easy",
|
||||
"susSha256": "ea9fbe7b4c4d466dc50cb30410bc2f410cd5d72fa3f4d58db3a1652c0fa0f3f1",
|
||||
"perfectNoteWeight": 177000
|
||||
},
|
||||
{
|
||||
"songId": "m0341",
|
||||
"difficulty": "hard",
|
||||
"susSha256": "139c990b740a2fb42e5a1c18580b02e91e9da16b0f49c8ab34e544a982404820",
|
||||
"perfectNoteWeight": 500350
|
||||
},
|
||||
{
|
||||
"songId": "m0341",
|
||||
"difficulty": "normal",
|
||||
"susSha256": "13c9e219f5d1ab5a47b78460ace319557c236b9b92574c78d8897bb64297f475",
|
||||
"perfectNoteWeight": 301800
|
||||
}
|
||||
]
|
||||
}
|
||||
+4
-1
@@ -24,7 +24,10 @@
|
||||
"data:apply-score-calibrations": "node scripts/apply-score-calibrations.mjs",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"data:apply-song-score-formulas": "node scripts/apply-song-score-formulas.mjs",
|
||||
"data:validate-song-score-formula": "node scripts/validate-song-score-formula.mjs",
|
||||
"test:sus-score": "node --test scripts/sus-score-weight.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
|
||||
@@ -21,6 +21,9 @@ for (const calibration of calibrations.charts) {
|
||||
// Easy/Normal 실측도 보관하되 현재 생성 대상인 Hard/Expert만 즉시 동기화한다.
|
||||
if (!catalogRow) continue;
|
||||
|
||||
// 공식 기반 채보는 실측 보정으로 덮어쓰지 않는다. 실측은 검증 자료로 유지한다.
|
||||
if (catalogRow.scoreCalibrationSource === 'formula-v1') continue;
|
||||
|
||||
catalogRow.scoreRatio = calibration.scoreRatio;
|
||||
catalogRow.scoreCalibrationSource = 'measured';
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { formulaScoreMetadata } from './song-score-formulas.mjs';
|
||||
|
||||
const root = new URL('../', import.meta.url);
|
||||
const catalogPath = new URL('src/lib/data/exact-chart-catalog.json', root);
|
||||
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
|
||||
const updates = [];
|
||||
const missing = [];
|
||||
// Validate every chart before writing any changes. Do not change notes or SP timing.
|
||||
for (const row of catalog.charts) {
|
||||
const chartPath = new URL(`static${row.path}`, root);
|
||||
const chart = JSON.parse(await readFile(chartPath, 'utf8'));
|
||||
const metadata = formulaScoreMetadata(row.songId, row.difficulty, row.difficultyLevel, chart.notes.length, undefined, chart.source?.susSha256);
|
||||
if (!metadata) { missing.push(`${row.songId}.${row.difficulty}`); continue; }
|
||||
if (chart.songId !== row.songId || chart.difficulty !== row.difficulty || row.totalNotes !== chart.notes.length) {
|
||||
throw new Error(`채보 카탈로그 불일치: ${row.songId}.${row.difficulty}`);
|
||||
}
|
||||
Object.assign(row, metadata);
|
||||
Object.assign(chart, metadata);
|
||||
updates.push({ chartPath, chart });
|
||||
}
|
||||
for (const { chartPath, chart } of updates) await writeFile(chartPath, JSON.stringify(chart));
|
||||
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
||||
console.log(`${updates.length}개 채보에 곡별 공식을 적용했습니다. 기존 환산 유지: ${missing.join(', ') || '없음'}`);
|
||||
@@ -0,0 +1,23 @@
|
||||
// node scripts/calculate-sus-score-formula.mjs data/raw/sus/chart_m0341_expert.sus
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { basename } from 'node:path';
|
||||
import { susScoreWeight } from './sus-score-weight.mjs';
|
||||
import { scoreRatioFromFormula } from '../src/lib/engine/score.ts';
|
||||
|
||||
const file = process.argv[2];
|
||||
if (!file) throw new Error('원본 SUS 경로를 지정하세요.');
|
||||
const bytes = await readFile(file);
|
||||
const result = susScoreWeight(bytes.toString('utf8'));
|
||||
const difficulty = basename(file).match(/_(easy|normal|hard|expert)\.sus$/)?.[1];
|
||||
const reference = JSON.parse(await readFile(new URL('../docs/research/song-score-formula-reference.json', import.meta.url), 'utf8'));
|
||||
const catalog = JSON.parse(await readFile(new URL('../src/lib/data/exact-chart-catalog.json', import.meta.url), 'utf8'));
|
||||
const chart = catalog.charts.find((row) => row.songId === result.songId && row.difficulty === difficulty);
|
||||
const song = reference.charts.find((row) => row.songId === result.songId);
|
||||
if (!chart || !song) throw new Error('카탈로그 난이도 또는 곡별 계수를 찾지 못했습니다.');
|
||||
const local = JSON.parse(await readFile(new URL(`../static${chart.path}`, import.meta.url), 'utf8'));
|
||||
const susSha256 = createHash('sha256').update(bytes).digest('hex');
|
||||
if (local.source?.susSha256 !== susSha256 || local.notes.length !== result.fullComboNoteCount) throw new Error('현재 서비스와 다른 원본 채보입니다.');
|
||||
// SUS SCORE_LEVEL is editor metadata; use the published master difficulty level.
|
||||
const formula = { perfectNoteWeight: result.perfectNoteWeight, liveScoreCoefficientPermil: song.liveScoreCoefficientPermil, difficultyLevel: chart.difficultyLevel };
|
||||
console.log(JSON.stringify({ ...result, difficulty, susSha256, ...formula, scoreRatio: scoreRatioFromFormula(formula) }, null, 2));
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { formulaScoreMetadata } from './song-score-formulas.mjs';
|
||||
|
||||
const sourceOrigin = process.argv[2] ?? 'https://dreams.wf-calc.net';
|
||||
const outputDirectory = path.resolve('static/data/charts');
|
||||
@@ -237,14 +238,18 @@ async function worker() {
|
||||
const releaseTime = Number(music?.startTime);
|
||||
const notes = flattenEvents(chartData);
|
||||
const measuredCalibration = scoreCalibrationByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`);
|
||||
const difficultyLevel = difficultyLevelByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`) ??
|
||||
(Number.isFinite(item.difficultyLevel) ? item.difficultyLevel : null);
|
||||
const formulaMetadata = formulaScoreMetadata(item.songId, item.difficulty, difficultyLevel, notes.length, music?.liveScoreCoefficientPermil, chartData.source?.sha256);
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
songId: item.songId,
|
||||
difficulty: item.difficulty.toLowerCase(),
|
||||
durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0),
|
||||
// 실측이 있으면 우선하고, 없을 때만 채보 가중치와 계산기 고유 상수로 추정한다.
|
||||
// 검증된 공식 우선. 참고 분모가 없는 채보만 기존 실측/추정으로 대체한다.
|
||||
scoreRatio: measuredCalibration?.scoreRatio ?? calibratedScoreRatio(chartData),
|
||||
scoreCalibrationSource: measuredCalibration ? 'measured' : 'local-estimated-v1',
|
||||
...formulaMetadata,
|
||||
notes,
|
||||
specialStartSeconds: (chartData.sp_activation_events ?? [])
|
||||
.sort((left, right) => left.slot - right.slot)
|
||||
@@ -268,6 +273,7 @@ async function worker() {
|
||||
durationSeconds: output.durationSeconds,
|
||||
scoreRatio: output.scoreRatio,
|
||||
scoreCalibrationSource: output.scoreCalibrationSource,
|
||||
...(output.scoreFormula ? { scoreFormula: output.scoreFormula } : {}),
|
||||
totalNotes: notes.length,
|
||||
singerType: output.singerType,
|
||||
singerCharacterIds: output.singerCharacterIds,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { scoreRatioFromFormula } from '../src/lib/engine/score.ts';
|
||||
|
||||
// Pinned research snapshot, with source URL, retrieval time and SHA-256.
|
||||
const reference = JSON.parse(await readFile(new URL('../docs/research/song-score-formula-reference.json', import.meta.url), 'utf8'));
|
||||
const susReference = JSON.parse(await readFile(new URL('../docs/research/sus-score-formulas.json', import.meta.url), 'utf8'));
|
||||
const rows = [...reference.charts, ...susReference.charts];
|
||||
const formulas = new Map(rows.map((row) => [`${row.songId}.${row.difficulty}`, row]));
|
||||
if (formulas.size !== rows.length) throw new Error('중복 악곡 점수 공식입니다.');
|
||||
|
||||
export function formulaScoreMetadata(songId, difficulty, difficultyLevel, totalNotes, liveScoreCoefficientPermil, susSha256) {
|
||||
const row = formulas.get(`${songId}.${difficulty.toLowerCase()}`);
|
||||
if (!row) return null;
|
||||
if (row.susSha256 && row.susSha256 !== susSha256) throw new Error(`${songId}.${difficulty}: 원본 SUS 해시가 다릅니다.`);
|
||||
if (row.difficultyLevel !== difficultyLevel || row.fullComboNoteCount !== totalNotes ||
|
||||
(liveScoreCoefficientPermil !== undefined && row.liveScoreCoefficientPermil !== liveScoreCoefficientPermil)) {
|
||||
throw new Error(`${songId}.${difficulty}: 채보 또는 마스터 데이터가 점수 공식 참고값과 다릅니다.`);
|
||||
}
|
||||
const scoreFormula = {
|
||||
perfectNoteWeight: row.perfectNoteWeight,
|
||||
liveScoreCoefficientPermil: row.liveScoreCoefficientPermil,
|
||||
difficultyLevel: row.difficultyLevel
|
||||
};
|
||||
return { scoreRatio: scoreRatioFromFormula(scoreFormula), scoreCalibrationSource: 'formula-v1', scoreFormula };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Counts the score denominator, not a playable-note timeline.
|
||||
// SUS headers supply playable categories, including generated continuation ticks.
|
||||
// Body family 3 kinds 4/5 supply non-playable bezier / inactive relay points.
|
||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const categories = ['NORMAL', 'FLICK', 'LONG_START', 'LONG_END', 'LONG_FLICK_END', 'LONG_RELAY', 'LONG_CONTINUE'];
|
||||
|
||||
export function susScoreWeight(text) {
|
||||
const headers = new Map();
|
||||
let invisibleRelayCount = 0;
|
||||
let bezierPointCount = 0;
|
||||
let starts = 0, ends = 0, relays = 0;
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const header = line.match(/^#([A-Z_]+)\s+(\S+)\s*$/);
|
||||
if (header) {
|
||||
if (headers.has(header[1])) throw new Error(`Duplicate SUS header: ${header[1]}`);
|
||||
headers.set(header[1], header[2]);
|
||||
}
|
||||
const match = line.match(/^#\d{3}3[0-9a-zA-Z]{2}:(.*)$/);
|
||||
if (!match) continue;
|
||||
const body = match[1].trim();
|
||||
// Repeated channel lines are additive; do not overwrite them in a map.
|
||||
for (let index = 0; index < body.length;) {
|
||||
if (body.startsWith('0x', index)) {
|
||||
const run = body.slice(index + 2, index + 4);
|
||||
if (run.length !== 2 || [...run].some((char) => !alphabet.includes(char))) throw new Error('Malformed SUS empty run');
|
||||
index += 4; // Empty cells have no denominator contribution.
|
||||
continue;
|
||||
}
|
||||
const token = body.slice(index, index + 2);
|
||||
if (token.length !== 2 || [...token].some((char) => !alphabet.includes(char))) throw new Error('Malformed SUS note token');
|
||||
const kind = token[0];
|
||||
if (kind === '1') starts += 1;
|
||||
else if (kind === '2') ends += 1;
|
||||
else if (kind === '3') relays += 1;
|
||||
else if (kind === '4') bezierPointCount += 1;
|
||||
else if (kind === '5') invisibleRelayCount += 1;
|
||||
else if (kind !== '0') throw new Error(`Unsupported SUS hold kind: ${kind}`);
|
||||
index += 2;
|
||||
}
|
||||
}
|
||||
function integer(name) {
|
||||
const raw = headers.get(name);
|
||||
if (!raw || !/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw))) throw new Error(`Invalid SUS header: ${name}`);
|
||||
return Number(raw);
|
||||
}
|
||||
const counts = Object.fromEntries(categories.map((kind) => [kind, integer(`${kind}_NOTE_COUNT`)]));
|
||||
// Ghosts are visual guides, not invisible hold relays; they add no score weight.
|
||||
integer('GHOST_NOTE_COUNT');
|
||||
for (const unsupported of ['DAMAGE_NOTE_COUNT']) {
|
||||
if (integer(unsupported) !== 0) throw new Error(`Unsupported scoring category: ${unsupported}`);
|
||||
}
|
||||
const fullComboNoteCount = integer('FULL_COMBO_NOTE_COUNT');
|
||||
if (Object.values(counts).reduce((sum, count) => sum + count, 0) !== fullComboNoteCount ||
|
||||
integer('TOTAL_NOTE_COUNT') !== fullComboNoteCount + integer('GHOST_NOTE_COUNT') ||
|
||||
starts !== counts.LONG_START || ends !== counts.LONG_END + counts.LONG_FLICK_END || relays !== counts.LONG_RELAY) {
|
||||
throw new Error('SUS header/body category mismatch');
|
||||
}
|
||||
const songId = headers.get('MUSIC_ID');
|
||||
if (!songId || !/^m\d{4}$/.test(songId)) throw new Error('Invalid SUS music ID');
|
||||
const baseWeight = 1000 * (counts.NORMAL + counts.LONG_START + counts.LONG_END + counts.LONG_FLICK_END)
|
||||
+ 1050 * counts.FLICK + 100 * (counts.LONG_RELAY + counts.LONG_CONTINUE);
|
||||
return {
|
||||
songId, fullComboNoteCount, counts, invisibleRelayCount, bezierPointCount, baseWeight,
|
||||
perfectNoteWeight: baseWeight + 100 * (invisibleRelayCount + bezierPointCount)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { susScoreWeight } from './sus-score-weight.mjs';
|
||||
|
||||
const text = `#MUSIC_ID m0001
|
||||
#FULL_COMBO_NOTE_COUNT 7
|
||||
#TOTAL_NOTE_COUNT 7
|
||||
#NORMAL_NOTE_COUNT 1
|
||||
#FLICK_NOTE_COUNT 1
|
||||
#LONG_START_NOTE_COUNT 1
|
||||
#LONG_END_NOTE_COUNT 0
|
||||
#LONG_FLICK_END_NOTE_COUNT 1
|
||||
#LONG_RELAY_NOTE_COUNT 1
|
||||
#LONG_CONTINUE_NOTE_COUNT 2
|
||||
#DAMAGE_NOTE_COUNT 0
|
||||
#GHOST_NOTE_COUNT 0
|
||||
#000320:140x0K3454
|
||||
#000320:0xft4424
|
||||
#00052:1c
|
||||
`;
|
||||
test('비가시·베지어는 분모에만 포함하고 끝 플릭은 1000으로 집계한다', () => {
|
||||
const result = susScoreWeight(text);
|
||||
assert.equal(result.fullComboNoteCount, 7);
|
||||
assert.equal(result.baseWeight, 4350);
|
||||
assert.equal(result.invisibleRelayCount, 1);
|
||||
assert.equal(result.bezierPointCount, 1);
|
||||
assert.equal(result.perfectNoteWeight, 4550);
|
||||
});
|
||||
test('잘린 토큰·미지원 종류·헤더 불일치는 잘못된 분모 대신 실패한다', () => {
|
||||
for (const changed of [text.replace('0xft', '0x!t'), text + '#001320:4', text + '#001320:64', text.replace('LONG_START_NOTE_COUNT 1', 'LONG_START_NOTE_COUNT 2')]) {
|
||||
assert.throws(() => susScoreWeight(changed));
|
||||
}
|
||||
});
|
||||
test('고스트 가이드는 총 오브젝트 수에는 포함하지만 점수 분모에는 포함하지 않는다', () => {
|
||||
const withGhost = text.replace('GHOST_NOTE_COUNT 0', 'GHOST_NOTE_COUNT 2').replace('TOTAL_NOTE_COUNT 7', 'TOTAL_NOTE_COUNT 9');
|
||||
assert.equal(susScoreWeight(withGhost).perfectNoteWeight, 4550);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// Read-only comparison: this does not replace production scoreRatio values.
|
||||
// Run: node scripts/validate-song-score-formula.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { scoreRatioFromFormula, finalNoteScore } from '../src/lib/engine/score.ts';
|
||||
|
||||
const root = new URL('../', import.meta.url);
|
||||
const read = async (relative) => JSON.parse(await readFile(new URL(relative, root), 'utf8'));
|
||||
const reference = await read('docs/research/song-score-formula-reference.json');
|
||||
const susReference = await read('docs/research/sus-score-formulas.json');
|
||||
const calibrations = await read('data/score-calibrations.json');
|
||||
const catalog = await read('src/lib/data/exact-chart-catalog.json');
|
||||
const key = (row) => `${row.songId}.${row.difficulty}`;
|
||||
const referenceRows = [...reference.charts, ...susReference.charts];
|
||||
const byKey = new Map(referenceRows.map((row) => [key(row), row]));
|
||||
assert.equal(byKey.size, referenceRows.length, 'Duplicate reference chart');
|
||||
|
||||
function ratio(row) {
|
||||
assert.ok(Number.isInteger(row.perfectNoteWeight) && row.perfectNoteWeight > 0);
|
||||
const coefficient = 2.3 * (1 + row.liveScoreCoefficientPermil / 1000 * (row.difficultyLevel - 5));
|
||||
assert.ok(coefficient > 0);
|
||||
return row.perfectNoteWeight / 1000 / coefficient;
|
||||
}
|
||||
|
||||
// Exact rational ceiling for the documented formula; no fitted ratios or epsilon.
|
||||
function predict(measurement, row) {
|
||||
const bonusScaled = Math.round(measurement.directSongScoreBonusPercent * 100);
|
||||
assert.ok(Math.abs(bonusScaled / 100 - measurement.directSongScoreBonusPercent) < 1e-10);
|
||||
assert.ok(Number.isInteger(measurement.overallPower));
|
||||
const numerator = BigInt(measurement.overallPower) * 23n
|
||||
* BigInt(1000 + row.liveScoreCoefficientPermil * (row.difficultyLevel - 5))
|
||||
* BigInt(10000 + bonusScaled);
|
||||
const denominator = 10n * BigInt(row.perfectNoteWeight) * 10000n;
|
||||
return Number((numerator + denominator - 1n) / denominator);
|
||||
}
|
||||
|
||||
const measuredRows = [];
|
||||
for (const calibration of calibrations.charts) {
|
||||
const ref = byKey.get(key(calibration));
|
||||
assert.ok(ref, `Missing measured reference: ${key(calibration)}`);
|
||||
const predictions = calibration.measurements.map((measurement) => {
|
||||
const predicted = predict(measurement, ref);
|
||||
// ceil(A / R) = S implies R in [A/S, A/(S-1)).
|
||||
const amount = measurement.overallPower * (1 + measurement.directSongScoreBonusPercent / 100);
|
||||
const lower = amount / measurement.normalPerfectScore;
|
||||
const upperExclusive = amount / (measurement.normalPerfectScore - 1);
|
||||
assert.ok(ratio(ref) >= lower && ratio(ref) < upperExclusive, `Ratio outside measured interval: ${key(calibration)}`);
|
||||
assert.equal(predicted, measurement.normalPerfectScore, `Measured score mismatch: ${key(calibration)}`);
|
||||
assert.equal(finalNoteScore(measurement.overallPower / scoreRatioFromFormula(ref), measurement.directSongScoreBonusPercent), predicted,
|
||||
`Production engine mismatch: ${key(calibration)}`);
|
||||
return { ...measurement, predicted, ratioInterval: { lower, upperExclusive } };
|
||||
});
|
||||
measuredRows.push({ key: key(calibration), researchRatio: ratio(ref), storedRatio: calibration.scoreRatio, predictions });
|
||||
}
|
||||
|
||||
const missing = [], comparisons = [], noteDiagnostics = [];
|
||||
for (const chart of catalog.charts) {
|
||||
const ref = byKey.get(key(chart));
|
||||
if (!ref) { missing.push(key(chart)); continue; }
|
||||
assert.equal(chart.difficultyLevel, ref.difficultyLevel, `Difficulty mismatch: ${key(chart)}`);
|
||||
const local = await read(`static${chart.path}`);
|
||||
assert.equal(chart.scoreCalibrationSource, 'formula-v1', `Formula not applied: ${key(chart)}`);
|
||||
assert.equal(chart.scoreRatio, scoreRatioFromFormula(ref));
|
||||
assert.equal(local.scoreRatio, chart.scoreRatio);
|
||||
assert.deepEqual(local.scoreFormula, chart.scoreFormula);
|
||||
assert.equal(local.notes.length, ref.fullComboNoteCount, `Combo count mismatch: ${key(chart)}`);
|
||||
const localWeight = local.notes.reduce((sum, note) => sum + (note.type === 'mid' ? 100 : note.isFlick ? 1050 : 1000), 0);
|
||||
noteDiagnostics.push({
|
||||
key: key(chart), localWeight, referencePlayableWeight: ref.baseWeight,
|
||||
referenceDenominator: ref.perfectNoteWeight,
|
||||
nonPlayableWeight: ref.perfectNoteWeight - ref.baseWeight,
|
||||
playableClassificationDifference: localWeight - ref.baseWeight
|
||||
});
|
||||
comparisons.push({ key: key(chart), title: chart.title, source: chart.scoreCalibrationSource,
|
||||
// Positive means the current unrounded base score is higher than research.
|
||||
currentScoreRelativeToResearchPercent: 100 * (ratio(ref) / chart.scoreRatio - 1) });
|
||||
}
|
||||
|
||||
const comparisonBySource = Object.fromEntries(Object.entries(Object.groupBy(comparisons, (row) => row.source)).map(([source, rows]) => {
|
||||
const ordered = rows.toSorted((a, b) => a.currentScoreRelativeToResearchPercent - b.currentScoreRelativeToResearchPercent);
|
||||
return [source, { count: rows.length,
|
||||
meanAbsoluteDifferencePercent: rows.reduce((sum, row) => sum + Math.abs(row.currentScoreRelativeToResearchPercent), 0) / rows.length,
|
||||
over5Percent: rows.filter((row) => Math.abs(row.currentScoreRelativeToResearchPercent) > 5).length,
|
||||
over10Percent: rows.filter((row) => Math.abs(row.currentScoreRelativeToResearchPercent) > 10).length,
|
||||
minimum: ordered[0], maximum: ordered.at(-1) }];
|
||||
}));
|
||||
const report = {
|
||||
reference: fileURLToPath(new URL('docs/research/song-score-formula-reference.json', root)),
|
||||
source: reference.source,
|
||||
measuredSongs: new Set(calibrations.charts.map((row) => row.songId)).size,
|
||||
measuredCharts: measuredRows.length,
|
||||
exactMeasurements: measuredRows.reduce((sum, row) => sum + row.predictions.length, 0),
|
||||
catalogCharts: catalog.charts.length, coveredCharts: comparisons.length, missing,
|
||||
comparisonBySource,
|
||||
measuredRows,
|
||||
measuredNoteDiagnostics: noteDiagnostics.filter((row) => measuredRows.some((measured) => measured.key === row.key))
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { susScoreWeight } from './sus-score-weight.mjs';
|
||||
|
||||
const record = JSON.parse(await readFile(new URL('../docs/research/sus-score-formulas.json', import.meta.url), 'utf8'));
|
||||
const directory = process.argv[2] ?? 'data/raw/sus';
|
||||
for (const row of [...record.validationCharts, ...record.charts]) {
|
||||
const bytes = await readFile(path.join(directory, `chart_${row.songId}_${row.difficulty}.sus`));
|
||||
assert.equal(createHash('sha256').update(bytes).digest('hex'), row.susSha256);
|
||||
const actual = susScoreWeight(bytes.toString('utf8'));
|
||||
assert.equal(actual.songId, row.songId);
|
||||
assert.equal(actual.perfectNoteWeight, row.perfectNoteWeight, `${row.songId}.${row.difficulty}`);
|
||||
if (row.counts) {
|
||||
assert.deepEqual(actual.counts, row.counts);
|
||||
assert.equal(actual.invisibleRelayCount, row.invisibleRelayCount);
|
||||
assert.equal(actual.bezierPointCount, row.bezierPointCount);
|
||||
}
|
||||
}
|
||||
console.log(`${record.validationCharts.length}개 기존 분모와 ${record.charts.length}개 신규 SUS 분모를 재현했습니다.`);
|
||||
+3104
-1164
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,25 @@ type ActiveCandidate = {
|
||||
// QUEEN 및 Our Promise Expert의 인게임 실측값으로 보정한 전역 환산 상수다.
|
||||
export const CHART_SCORE_WEIGHT_COEFFICIENT = 0.3713416563352296;
|
||||
|
||||
export type SongScoreFormula = {
|
||||
/** 일반 PERFECT=1000 단위. 비가시 지점을 포함하며 득점 노트 합과 다르다. */
|
||||
perfectNoteWeight: number;
|
||||
liveScoreCoefficientPermil: number;
|
||||
difficultyLevel: number;
|
||||
};
|
||||
|
||||
export function scoreRatioFromFormula(formula: SongScoreFormula): number {
|
||||
const { perfectNoteWeight, liveScoreCoefficientPermil, difficultyLevel } = formula;
|
||||
if (!Number.isSafeInteger(perfectNoteWeight) || perfectNoteWeight <= 0 ||
|
||||
!Number.isSafeInteger(liveScoreCoefficientPermil) || liveScoreCoefficientPermil < 0 ||
|
||||
!Number.isSafeInteger(difficultyLevel) || difficultyLevel < 1) {
|
||||
throw new Error('악곡 점수 공식의 입력값이 유효하지 않습니다.');
|
||||
}
|
||||
const musicCoefficient = 1 + liveScoreCoefficientPermil / 1000 * (difficultyLevel - 5);
|
||||
if (musicCoefficient <= 0) throw new Error('악곡 점수 배율은 양수여야 합니다.');
|
||||
return perfectNoteWeight / 1000 / (2.3 * musicCoefficient);
|
||||
}
|
||||
|
||||
export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number {
|
||||
let weight = 0;
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import reference from '../../../docs/research/song-score-formula-reference.json' with { type: 'json' };
|
||||
import susReference from '../../../docs/research/sus-score-formulas.json' with { type: 'json' };
|
||||
import calibrations from '../../../data/score-calibrations.json' with { type: 'json' };
|
||||
import catalog from '../data/exact-chart-catalog.json' with { type: 'json' };
|
||||
import { calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromFormula } from './score.ts';
|
||||
|
||||
test('곡별 공식과 실제 점수 엔진이 일반 PERFECT 실측 19건을 재현한다', () => {
|
||||
let count = 0;
|
||||
for (const calibration of calibrations.charts) {
|
||||
const formula = reference.charts.find((row) => row.songId === calibration.songId && row.difficulty === calibration.difficulty);
|
||||
assert.ok(formula);
|
||||
for (const measurement of calibration.measurements) {
|
||||
const result = calculateExactScore([], {}, {
|
||||
songId: calibration.songId, difficulty: calibration.difficulty,
|
||||
durationSeconds: 2, scoreRatio: scoreRatioFromFormula(formula),
|
||||
notes: [{ timeSeconds: 1, type: 'regular', isFlick: false }], specialStartSeconds: []
|
||||
}, {
|
||||
perfectNoteScore: perfectNoteScoreFromPower(measurement.overallPower, scoreRatioFromFormula(formula)),
|
||||
directSongScoreBonusPercent: measurement.directSongScoreBonusPercent
|
||||
});
|
||||
assert.equal(result.baselineScore, measurement.normalPerfectScore, `${calibration.songId}.${calibration.difficulty}`);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
assert.equal(count, 19);
|
||||
});
|
||||
|
||||
test('공식 적용 카탈로그와 실제 채보가 같은 분모 및 환산값을 사용한다', async () => {
|
||||
let formulaCount = 0;
|
||||
for (const row of catalog.charts) {
|
||||
const chart = JSON.parse(await readFile(new URL(`../../../static${row.path}`, import.meta.url), 'utf8'));
|
||||
const ref = [...reference.charts, ...susReference.charts].find((item) => item.songId === row.songId && item.difficulty === row.difficulty);
|
||||
assert.equal(chart.scoreRatio, row.scoreRatio);
|
||||
assert.equal(chart.scoreCalibrationSource, row.scoreCalibrationSource);
|
||||
if (!ref) {
|
||||
assert.notEqual(row.scoreCalibrationSource, 'formula-v1');
|
||||
continue;
|
||||
}
|
||||
assert.equal(row.scoreCalibrationSource, 'formula-v1');
|
||||
assert.equal(row.scoreRatio, scoreRatioFromFormula(ref));
|
||||
assert.deepEqual(chart.scoreFormula, row.scoreFormula);
|
||||
assert.deepEqual(chart.scoreFormula, {
|
||||
perfectNoteWeight: ref.perfectNoteWeight,
|
||||
liveScoreCoefficientPermil: ref.liveScoreCoefficientPermil,
|
||||
difficultyLevel: ref.difficultyLevel
|
||||
});
|
||||
assert.equal(chart.notes.length, ref.fullComboNoteCount);
|
||||
formulaCount += 1;
|
||||
}
|
||||
assert.equal(formulaCount, 388);
|
||||
});
|
||||
|
||||
test('잘못된 분모와 난이도로 공식 환산값을 만들지 않는다', () => {
|
||||
const valid = { perfectNoteWeight: 633950, liveScoreCoefficientPermil: 5, difficultyLevel: 24 };
|
||||
for (const invalid of [{ perfectNoteWeight: 0 }, { perfectNoteWeight: NaN }, { difficultyLevel: 0 }, { liveScoreCoefficientPermil: -1 }]) {
|
||||
assert.throws(() => scoreRatioFromFormula({ ...valid, ...invalid }));
|
||||
}
|
||||
});
|
||||
@@ -93,6 +93,7 @@ export type ExactSongChart = {
|
||||
durationSeconds: number;
|
||||
scoreRatio: number | null;
|
||||
scoreCalibrationSource?: 'measured' | 'local-estimated-v1' | string;
|
||||
scoreFormula?: import('./score.ts').SongScoreFormula;
|
||||
notes: readonly ExactChartNote[];
|
||||
specialStartSeconds: readonly (number | null)[];
|
||||
singerType?: string | null;
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
})();
|
||||
$: overallPower = unitPower.overallPower;
|
||||
$: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null);
|
||||
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
|
||||
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'formula-v1' ? '곡별 공식' : selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
|
||||
$: timelineDuration = selectedChart?.durationSeconds ?? 0;
|
||||
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []);
|
||||
$: timelineTicks = buildTimelineTicks(timelineDuration);
|
||||
@@ -859,7 +859,7 @@
|
||||
<div>
|
||||
<h1>배치 분석</h1>
|
||||
<p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.</p>
|
||||
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured'}
|
||||
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
|
||||
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
$: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
|
||||
$: if (!songs.some((song) => song.id === selectedSongId)) selectedSongId = songs[0]?.id ?? '';
|
||||
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
|
||||
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
|
||||
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'formula-v1' ? '곡별 공식' : selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
|
||||
$: baselineChartMetadata = baselineState
|
||||
? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty)
|
||||
: undefined;
|
||||
@@ -1129,7 +1129,7 @@
|
||||
<span>대상 멤버 1명당 +{currentEvent.formationBonusPercent}% · 신규 카드 추가 +{currentEvent.featuredCardBonusPercent}% · 과제곡 대응 카드 스코어 +{currentEvent.challengeSongScoreBonusPercent}%</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured'}
|
||||
{#if selectedChart && selectedChart.scoreCalibrationSource !== 'measured' && selectedChart.scoreCalibrationSource !== 'formula-v1'}
|
||||
<p class="calibration-warning" role="status"><strong>추정 점수 안내</strong> 이 악곡의 점수 계수는 실측값이 아니므로 실제 점수와 오차가 크게 발생할 수 있습니다.</p>
|
||||
{/if}
|
||||
{#if !eventMode}<p class="baseline-guide"><strong>기준 조합</strong>은 배치 분석 결과가 저장되어 있고, 저장된 악곡과 난이도가 현재 탐색 조건과 같을 때 표시됩니다.</p>{/if}
|
||||
|
||||
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
Reference in New Issue
Block a user