fix: repair SUS timing and note classification for 18 charts
Publish GitHub Pages output / publish (push) Successful in 47s
Publish GitHub Pages output / publish (push) Successful in 47s
This commit is contained in:
@@ -4,6 +4,10 @@ updatedAt: 2026-09-09T12:00:00+09:00
|
|||||||
|
|
||||||
## 2026년 9월 9일
|
## 2026년 9월 9일
|
||||||
|
|
||||||
|
### 일부 악곡의 노트 타이밍·분류 수정
|
||||||
|
|
||||||
|
`FIXED` HoloHawk, Tri-Unity♡LoveSystem, Simulacre, Illusion Night, Mekurumeku Rendezvous의 영향받은 18개 난이도 채보를 수정했습니다. 롱노트 끝 중복으로 후반 노트가 누락되던 문제와 중간 판정 시각을 바로잡고, 롱노트 끝 플릭은 일반 노트와 같은 점수로 계산하도록 분류했습니다.
|
||||||
|
|
||||||
### FUWAMOCO 솔로 악곡 보너스 수정
|
### FUWAMOCO 솔로 악곡 보너스 수정
|
||||||
|
|
||||||
`FIXED` 후와와와 모코코 둘만 가창하는 FUWAMOCO 악곡을 솔로 악곡으로 판정하여, 두 멤버의 솔로 악곡 보너스가 함께 적용되도록 수정했습니다.
|
`FIXED` 후와와와 모코코 둘만 가창하는 FUWAMOCO 악곡을 솔로 악곡으로 판정하여, 두 멤버의 솔로 악곡 보너스가 함께 적용되도록 수정했습니다.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 18개 난이도 채보 수정 검증
|
||||||
|
|
||||||
|
기준: [Song Mechanics](https://docs.google.com/document/d/1P1newxmgxS8b-0raHk5vqnbb8X1nXh1xs3d4gYuhUMQ), Note Score 절.
|
||||||
|
|
||||||
|
일반·롱노트 시작/끝/끝 플릭 1000, 중간·릴레이 100, 독립 플릭 1050을 적용했다. 비가시 릴레이·베지어는 득점 타임라인에 넣지 않는다. PC 독립 플릭 1000 규칙은 이번 채보 수정과 별개의 플랫폼 계산 옵션이므로 변경하지 않았다.
|
||||||
|
|
||||||
|
롱노트 끝의 중복 tap을 제외하고, 중간 판정을 곡 전체의 0.5박 격자로 생성한다. 마디 길이는 다음 변경까지 유지한다. 헤더와 종류별 개수가 다르면 수집을 중단하며, 노트를 삭제하거나 마지막에 추가하지 않는다.
|
||||||
|
|
||||||
|
18개 모두 원본 헤더의 개수 및 분류와 일치하고, wf-calc 스냅샷과 원본 SHA-256 및 전체 노트 시각이 1마이크로초 이내에서 일치했다. wf-calc의 점수 분류를 그대로 복사하지 않았다. 기존 1노트 점수 환산값·분모·스페셜 시각은 모두 유지했다. FUWAMOCO 가창 분류는 공식 마스터의 solo로 바로잡았다.
|
||||||
|
|
||||||
|
이 결과만으로 특정 편성의 330만/350만 차이 전체를 설명한다고 단정하지 않는다.
|
||||||
|
|
||||||
|
|곡 ID|난이도|전체|일반(롱 끝 포함)|독립 플릭|중간|마지막 시각|
|
||||||
|
|---|---|---:|---:|---:|---:|---:|
|
||||||
|
|m0344|normal|472|361|0|111|150.000000|
|
||||||
|
|m0344|hard|794|579|70|145|150.000000|
|
||||||
|
|m0344|expert|1106|864|114|128|150.000000|
|
||||||
|
|m0348|easy|206|122|0|84|125.142857|
|
||||||
|
|m0348|normal|333|236|0|97|125.142857|
|
||||||
|
|m0348|hard|637|447|19|171|125.142857|
|
||||||
|
|m0348|expert|850|581|34|235|125.142857|
|
||||||
|
|m0363|easy|274|168|0|106|137.806452|
|
||||||
|
|m0363|normal|574|343|0|231|139.161290|
|
||||||
|
|m0363|expert|1222|1007|57|158|139.161290|
|
||||||
|
|m0459|easy|255|124|0|131|125.227273|
|
||||||
|
|m0459|normal|326|182|0|144|125.227273|
|
||||||
|
|m0459|hard|616|411|27|178|125.227273|
|
||||||
|
|m0459|expert|870|617|62|191|125.227273|
|
||||||
|
|m0527|easy|102|78|0|24|106.430155|
|
||||||
|
|m0527|normal|285|230|0|55|108.292683|
|
||||||
|
|m0527|hard|420|324|20|76|108.292683|
|
||||||
|
|m0527|expert|682|547|27|108|108.292683|
|
||||||
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"data:apply-song-score-formulas": "node scripts/apply-song-score-formulas.mjs",
|
"data:apply-song-score-formulas": "node scripts/apply-song-score-formulas.mjs",
|
||||||
"data:validate-song-score-formula": "node scripts/validate-song-score-formula.mjs",
|
"data:validate-song-score-formula": "node scripts/validate-song-score-formula.mjs",
|
||||||
"test:sus-score": "node --test scripts/sus-score-weight.test.mjs"
|
"test:sus-score": "node --test scripts/sus-score-weight.test.mjs scripts/sus-timeline.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.5.7",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Read-only audit: never writes service charts or pads/truncates the reference timeline.
|
||||||
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { prepareHolodoriDbSource, readHolodoriDbText } from './holodoridb-source.mjs';
|
||||||
|
import { susScoreWeight } from './sus-score-weight.mjs';
|
||||||
|
|
||||||
|
const source = prepareHolodoriDbSource('kor', { review: true });
|
||||||
|
const music = new Map(JSON.parse(readHolodoriDbText(source, 'Music.json')).map(({ data }) => [data.id, data]));
|
||||||
|
const titles = new Map(JSON.parse(readHolodoriDbText(source, 'LangMusic_Kor.json')).map(({ data }) => [data.id, data.text]));
|
||||||
|
const catalog = JSON.parse(await readFile('src/lib/data/exact-chart-catalog.json', 'utf8'));
|
||||||
|
// Keep the original faulty parser fixed for reproducible historical comparison.
|
||||||
|
const converter = execFileSync('git', ['show', 'bb3573c:scripts/sus-to-exact-chart.mjs'], { encoding: 'utf8' });
|
||||||
|
const parser = converter.slice(converter.indexOf('const alphabet'), converter.indexOf('const music ='));
|
||||||
|
function replaceOnce(text, before, after) {
|
||||||
|
if (!text.includes(before) || text.indexOf(before) !== text.lastIndexOf(before)) throw new Error('Converter changed: audit instrumentation needs review.');
|
||||||
|
return text.replace(before, after);
|
||||||
|
}
|
||||||
|
let legacy = replaceOnce(parser, 'const notes = [];', 'const notes = []; let removed = 0, added = 0;');
|
||||||
|
legacy = replaceOnce(legacy, 'notes.splice(index, 1);', '{ notes.splice(index, 1); removed++; }');
|
||||||
|
legacy = replaceOnce(legacy, 'const anchor = notes.at(-1);', 'added++; const anchor = notes.at(-1);');
|
||||||
|
legacy = replaceOnce(legacy, 'return { songId: headers.get', 'return { removed, added, songId: headers.get');
|
||||||
|
// Diagnostic candidate only: remove the known endpoint double count, and expose unresolved
|
||||||
|
// continuation discrepancies instead of manufacturing notes. This is NOT a proven game parser.
|
||||||
|
let candidate = replaceOnce(parser, '|| holdStartKeys.has(key(tap)))', '|| holdStartKeys.has(key(tap)) || holdEndKeys.has(key(tap)))');
|
||||||
|
candidate = replaceOnce(candidate, 'notes.splice(index, 1);', '{}');
|
||||||
|
candidate = replaceOnce(candidate, "while (notes.filter((note) => note.type === 'mid').length < expectedMid)", 'if (false)');
|
||||||
|
// SUS measure lengths remain in effect until the next change.
|
||||||
|
candidate = replaceOnce(candidate, 'const starts = measureStarts(lengths);', 'let currentLength = 4; for (let m = 0; m < 1000; m++) { currentLength = lengths.get(m) ?? currentLength; lengths.set(m, currentLength); } const starts = measureStarts(lengths);');
|
||||||
|
const legacyContext = vm.createContext({});
|
||||||
|
const candidateContext = vm.createContext({});
|
||||||
|
vm.runInContext(legacy, legacyContext);
|
||||||
|
vm.runInContext(candidate, candidateContext);
|
||||||
|
const counts = (notes) => ({ regular: notes.filter(n => n.type !== 'mid' && !n.isFlick).length, flick: notes.filter(n => n.isFlick).length, mid: notes.filter(n => n.type === 'mid').length });
|
||||||
|
const sameCounts = (a, b) => Object.keys(a).every(k => a[k] === b[k]);
|
||||||
|
// Match multisets within 1 microsecond, including simultaneous notes; array ordering is irrelevant.
|
||||||
|
function differences(a, b) {
|
||||||
|
const sort = notes => [...notes].sort((x,y) => x.timeSeconds-y.timeSeconds);
|
||||||
|
const extra = [], missing = [];
|
||||||
|
for (const kind of ['regular', 'flick', 'mid']) {
|
||||||
|
const filter = n => (n.type === 'mid' ? 'mid' : n.isFlick ? 'flick' : 'regular') === kind;
|
||||||
|
const left = sort(a.filter(filter)), right = sort(b.filter(filter));
|
||||||
|
let i = 0, j = 0;
|
||||||
|
while (i < left.length || j < right.length) {
|
||||||
|
if (i < left.length && j < right.length && Math.abs(left[i].timeSeconds-right[j].timeSeconds) <= 1e-6) { i++; j++; }
|
||||||
|
else if (j >= right.length || (i < left.length && left[i].timeSeconds < right[j].timeSeconds)) extra.push({ kind, time: left[i++].timeSeconds });
|
||||||
|
else missing.push({ kind, time: right[j++].timeSeconds });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { extra, missing };
|
||||||
|
}
|
||||||
|
const rows = [];
|
||||||
|
for (const entry of catalog.charts) {
|
||||||
|
const bytes = await readFile(`data/raw/sus/chart_${entry.songId}_${entry.difficulty}.sus`);
|
||||||
|
const text = bytes.toString('utf8');
|
||||||
|
const chart = JSON.parse(await readFile(`static${entry.path}`, 'utf8'));
|
||||||
|
const weight = susScoreWeight(text);
|
||||||
|
const expected = { regular: weight.counts.NORMAL+weight.counts.LONG_START+weight.counts.LONG_END+weight.counts.LONG_FLICK_END, flick: weight.counts.FLICK, mid: weight.counts.LONG_RELAY+weight.counts.LONG_CONTINUE };
|
||||||
|
legacyContext.input = candidateContext.input = text;
|
||||||
|
const old = vm.runInContext('parseSusTimeline(input)', legacyContext);
|
||||||
|
const reference = vm.runInContext('parseSusTimeline(input)', candidateContext);
|
||||||
|
const legacyDiff = differences(chart.notes, old.notes);
|
||||||
|
const diff = differences(chart.notes, reference.notes);
|
||||||
|
const specialMismatch = chart.specialStartSeconds.length !== reference.specialStartSeconds.length || chart.specialStartSeconds.some((t,i) => Math.abs(t-reference.specialStartSeconds[i]) > 1e-6);
|
||||||
|
rows.push({ songId: entry.songId, title: titles.get(music.get(entry.songId)?.titleLangId) ?? entry.title, difficulty: entry.difficulty,
|
||||||
|
sourceHashMatches: chart.source.susSha256 === createHash('sha256').update(bytes).digest('hex'),
|
||||||
|
expected, actual: counts(chart.notes), categoryMismatch: !sameCounts(expected, counts(chart.notes)),
|
||||||
|
formulaWeight: { actual: chart.scoreFormula?.perfectNoteWeight, expected: weight.perfectNoteWeight },
|
||||||
|
specialMismatch, actualSpecial: chart.specialStartSeconds, referenceSpecial: reference.specialStartSeconds,
|
||||||
|
legacy: { matches: !legacyDiff.extra.length && !legacyDiff.missing.length, removed: old.removed, added: old.added },
|
||||||
|
candidate: { counts: counts(reference.notes), headerCountsMatch: sameCounts(expected, counts(reference.notes)), ...diff }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const summary = {
|
||||||
|
charts: rows.length, songs: new Set(rows.map(r=>r.songId)).size,
|
||||||
|
sourceHashMismatch: rows.filter(r=>!r.sourceHashMatches).length,
|
||||||
|
categoryMismatch: rows.filter(r=>r.categoryMismatch).length,
|
||||||
|
categoryMismatchByDifficulty: Object.fromEntries(['easy','normal','hard','expert'].map(d=>[d,rows.filter(r=>r.difficulty===d&&r.categoryMismatch).length])),
|
||||||
|
formulaMismatch: rows.filter(r=>r.formulaWeight.actual!==r.formulaWeight.expected).length,
|
||||||
|
specialMismatch: rows.filter(r=>r.specialMismatch).length,
|
||||||
|
matchesLegacy: rows.filter(r=>r.legacy.matches).length,
|
||||||
|
confirmedLegacySurgery: rows.filter(r=>r.legacy.matches && (r.legacy.removed || r.legacy.added)).length,
|
||||||
|
candidateHeaderMatch: rows.filter(r=>r.candidate.headerCountsMatch).length,
|
||||||
|
candidateTimelineMatch: rows.filter(r=>!r.candidate.extra.length&&!r.candidate.missing.length).length,
|
||||||
|
candidateHeaderMatchButTimelineDiff: rows.filter(r=>r.candidate.headerCountsMatch&&(r.candidate.extra.length||r.candidate.missing.length)).length,
|
||||||
|
candidateUnresolved: rows.filter(r=>!r.candidate.headerCountsMatch).length
|
||||||
|
};
|
||||||
|
await mkdir('data/sus-audit', { recursive: true });
|
||||||
|
await writeFile('data/sus-audit/latest.json', JSON.stringify({generatedAt:new Date().toISOString(),source,summary,notes:'Candidate is diagnostic, not a verified replacement. Header agreement alone does not prove timing. Differences use a 1 microsecond tolerance.',rows},null,2));
|
||||||
|
const lines = ['# 전체 SUS 채보 대조', '',
|
||||||
|
`전체 ${summary.songs}곡·${summary.charts}개 난이도를 대조했습니다. 서비스 파일은 변경하지 않았습니다.`, '',
|
||||||
|
'## 원본에서 직접 확인한 차이', '',
|
||||||
|
`- 노트 종류별 개수 불일치: ${summary.categoryMismatch}개. 전체 노트 수가 같아도 일반·플릭·중간 판정 구성이 다릅니다.`,
|
||||||
|
`- 원본 SHA-256 변경: ${summary.sourceHashMismatch}개. 기존 파일의 기록된 원본과 이번 수집본이 다릅니다.`,
|
||||||
|
`- 점수 분모 변경: ${summary.formulaMismatch}개. Pretty Guardian Matsulimit Zero Hard 544300 → 539500, Expert 874750 → 884700.`,
|
||||||
|
`- 스페셜 시각 차이: ${summary.specialMismatch}개. Pretty Guardian Matsulimit Zero 전 난이도.`, '',
|
||||||
|
'## 타이밍 진단의 범위와 한계', '',
|
||||||
|
'진단용 재구성은 롱노트 끝 중복 및 강제 삭제·추가를 제거하고, 마디 길이가 다음 변경까지 지속되도록 처리한 후보입니다. 게임의 완전한 정답 파서로 검증된 것은 아닙니다. 시각 오차 허용치는 1마이크로초입니다.',
|
||||||
|
`- 후보와 종류·시각이 모두 일치: ${summary.candidateTimelineMatch}개. 이 비교 범위에서 차이가 없다는 뜻이며 게임 전체 동작의 보증은 아닙니다.`,
|
||||||
|
`- 후보 종류별 개수는 원본 헤더와 맞지만 현재 타이밍과 차이: ${summary.candidateHeaderMatchButTimelineDiff}개. 상세 JSON에 초과·누락 시각을 기록했습니다.`,
|
||||||
|
`- 후보의 종류별 개수가 헤더와 불일치: ${summary.candidateUnresolved}개. 특히 롱노트 중간 판정 재구성은 추가 조사가 필요하며 자동 교체하면 안 됩니다.`,
|
||||||
|
`- 강제 삭제·추가를 수행한 기존 변환기 결과와 일치: ${summary.confirmedLegacySurgery}개. 변환 이력 자체를 증명하는 지표는 아닙니다.`,
|
||||||
|
'- 각 지표는 중복되므로 합산해서 오류 채보 수로 해석하면 안 됩니다.',
|
||||||
|
'- 타이밍과 스킬 구간의 관계에 따라 총점 오차 방향이 달라집니다. 특정 편성의 20만 점 차이를 이 보고서만으로 확정할 수 없습니다.', '',
|
||||||
|
'## 집계', '', '```json', JSON.stringify(summary,null,2), '```', '', '## 전곡 결과', '', '|곡|난이도|종류 수 불일치|원본 해시 변경|기존 변환 일치 / 삭제 / 추가|후보 헤더 일치|후보 대비 초과 / 누락|스페셜 차이|', '|---|---|---|---|---|---|---|---|'];
|
||||||
|
for (const r of rows) lines.push(`|${r.title}|${r.difficulty}|${r.categoryMismatch}|${!r.sourceHashMatches}|${r.legacy.matches} / ${r.legacy.removed} / ${r.legacy.added}|${r.candidate.headerCountsMatch}|${r.candidate.extra.length} / ${r.candidate.missing.length}|${r.specialMismatch}|`);
|
||||||
|
await writeFile('data/sus-audit/latest.md', lines.join('\n')+'\n');
|
||||||
|
console.log(JSON.stringify(summary,null,2));
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Fetch public wf-calc chart snapshots for comparison, without changing service data.
|
||||||
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { prepareHolodoriDbSource } from './holodoridb-source.mjs';
|
||||||
|
|
||||||
|
const source = prepareHolodoriDbSource('kor', { review: true });
|
||||||
|
const origin = 'https://dreams.wf-calc.net';
|
||||||
|
const directory = 'data/wf-audit';
|
||||||
|
await mkdir(`${directory}/charts`, { recursive: true });
|
||||||
|
async function get(url) {
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(60000) });
|
||||||
|
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
const home = await get(origin);
|
||||||
|
const mainPath = home.match(/<script type="module" crossorigin src="([^"]+)"/)?.[1];
|
||||||
|
if (!mainPath) throw new Error('Main module not found');
|
||||||
|
const main = await get(new URL(mainPath, origin));
|
||||||
|
await writeFile(`${directory}/main.js`, main);
|
||||||
|
const simulatorPath = main.match(/\.\/UnitSimulatorPage-([^"']+\.js)/)?.[1];
|
||||||
|
if (!simulatorPath) throw new Error('Simulator module not found');
|
||||||
|
const simulatorUrl = `${origin}/assets/UnitSimulatorPage-${simulatorPath}`;
|
||||||
|
const simulator = await get(simulatorUrl);
|
||||||
|
await writeFile(`${directory}/simulator.js`, simulator);
|
||||||
|
const imports = [...simulator.matchAll(/charts\/([^"/]+\.json)"[^\n]{0,180}?import\("\.\/([^"/]+\.js)"/g)].map(([,name,asset]) => {
|
||||||
|
const match = name.match(/^(m\d{4})\.(easy|normal|hard|expert)-(\d+)\.json$/i);
|
||||||
|
return match ? { songId: match[1], difficulty: match[2].toLowerCase(), level: Number(match[3]), name, asset } : null;
|
||||||
|
}).filter(Boolean);
|
||||||
|
if (!imports.length) throw new Error('No chart modules found');
|
||||||
|
if (new Set(imports.map(i=>`${i.songId}.${i.difficulty}`)).size !== imports.length) throw new Error('Duplicate chart modules');
|
||||||
|
// Reuse only the existing local data decoder/adapter; remote JS is never executed.
|
||||||
|
const collector = await readFile('scripts/collect-exact-charts.mjs', 'utf8');
|
||||||
|
const context = vm.createContext({});
|
||||||
|
vm.runInContext(collector.slice(collector.indexOf('function requiredMatch'), collector.indexOf('function legacyCalibrationNotes')), context);
|
||||||
|
const local = JSON.parse(await readFile('src/lib/data/exact-chart-catalog.json', 'utf8'));
|
||||||
|
const sus = JSON.parse(await readFile('data/sus-audit/latest.json', 'utf8'));
|
||||||
|
if (sus.source.commit !== source.commit) throw new Error('SUS audit source changed: rerun it first');
|
||||||
|
const rows = [];
|
||||||
|
let cursor = 0;
|
||||||
|
console.log(`wf-calc ${imports.length}개 채보 비교 시작`);
|
||||||
|
const counts = notes => ({ regular:notes.filter(n=>n.type!=='mid'&&!n.isFlick).length, flick:notes.filter(n=>n.isFlick).length, mid:notes.filter(n=>n.type==='mid').length });
|
||||||
|
function compare(a,b,kind=true) {
|
||||||
|
let extra=0,missing=0;
|
||||||
|
for(const type of kind?['regular','flick','mid']:['all']) {
|
||||||
|
const filter=n=>type==='all'||(n.type==='mid'?'mid':n.isFlick?'flick':'regular')===type;
|
||||||
|
const x=a.filter(filter).map(n=>n.timeSeconds).sort((a,b)=>a-b), y=b.filter(filter).map(n=>n.timeSeconds).sort((a,b)=>a-b);
|
||||||
|
let i=0,j=0;
|
||||||
|
while(i<x.length||j<y.length) {
|
||||||
|
if(i<x.length&&j<y.length&&Math.abs(x[i]-y[j])<=1e-6){i++;j++;}
|
||||||
|
else if(j>=y.length||(i<x.length&&x[i]<y[j])){extra++;i++;}else{missing++;j++;}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {extra,missing};
|
||||||
|
}
|
||||||
|
async function worker() {
|
||||||
|
while(cursor<imports.length) {
|
||||||
|
const item=imports[cursor++];
|
||||||
|
const url=`${origin}/assets/${item.asset}`;
|
||||||
|
const module=await get(url);
|
||||||
|
context.input=module;
|
||||||
|
const data=JSON.parse(vm.runInContext('extractJsonParseArgument(input)',context));
|
||||||
|
await writeFile(`${directory}/charts/${item.songId}.${item.difficulty}.json`,JSON.stringify(data));
|
||||||
|
context.input=data;
|
||||||
|
const notes=vm.runInContext('flattenEvents(input)',context);
|
||||||
|
const entry=local.charts.find(c=>c.songId===item.songId&&c.difficulty===item.difficulty);
|
||||||
|
if(!entry){rows.push({...item,localMissing:true});continue;}
|
||||||
|
const chart=JSON.parse(await readFile(`static${entry.path}`,'utf8'));
|
||||||
|
const reference=sus.rows.find(r=>r.songId===item.songId&&r.difficulty===item.difficulty);
|
||||||
|
const special=(data.sp_activation_events??[]).toSorted((a,b)=>a.slot-b.slot).map(e=>e.time_seconds);
|
||||||
|
const category=counts(notes);
|
||||||
|
const differentSpecial=other=>special.length!==other.length||special.some((t,i)=>Math.abs(t-other[i])>1e-6);
|
||||||
|
rows.push({...item,title:reference.title,url,moduleSha256:createHash('sha256').update(module).digest('hex'),
|
||||||
|
wfSource:data.source,localSource:chart.source,wfCounts:category,susExpected:reference.expected,
|
||||||
|
wfCategoryMatchesSus:Object.keys(category).every(k=>category[k]===reference.expected[k]),
|
||||||
|
localVsWf:compare(chart.notes,notes),localVsWfTimesOnly:compare(chart.notes,notes,false),
|
||||||
|
wfSpecial:special,localSpecialMismatch:differentSpecial(chart.specialStartSeconds),susSpecialMismatch:differentSpecial(reference.referenceSpecial),
|
||||||
|
metadataKeys:Object.keys(data).filter(k=>k!=='note_events')});
|
||||||
|
if(rows.length%100===0||rows.length===imports.length)console.log(`${rows.length}/${imports.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({length:6},worker));
|
||||||
|
rows.sort((a,b)=>a.songId.localeCompare(b.songId)||a.difficulty.localeCompare(b.difficulty));
|
||||||
|
const missing=local.charts.filter(c=>!rows.some(r=>r.songId===c.songId&&r.difficulty===c.difficulty)).map(c=>({songId:c.songId,title:c.title,difficulty:c.difficulty}));
|
||||||
|
const shared=rows.filter(r=>!r.localMissing);
|
||||||
|
const summary={wfCharts:rows.length,shared:shared.length,missingOnWf:missing.length,localTimelineMatches:shared.filter(r=>!r.localVsWf.extra&&!r.localVsWf.missing).length,localTimesOnlyMatches:shared.filter(r=>!r.localVsWfTimesOnly.extra&&!r.localVsWfTimesOnly.missing).length,wfCategoryMismatchSus:shared.filter(r=>!r.wfCategoryMatchesSus).length,localSpecialMismatch:shared.filter(r=>r.localSpecialMismatch).length,wfSpecialMismatchSus:shared.filter(r=>r.susSpecialMismatch).length};
|
||||||
|
await writeFile(`${directory}/latest.json`,JSON.stringify({generatedAt:new Date().toISOString(),source,simulatorUrl,summary,missingOnWf:missing,adapter:'Existing local flattenEvents adapter; adapter-derived classifications must not be attributed to the wf-calc scoring engine.',rows},null,2));
|
||||||
|
console.log(JSON.stringify(summary,null,2));
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import { createDecipheriv, createHash } from 'node:crypto';
|
import { createDecipheriv, createHash } from 'node:crypto';
|
||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { prepareHolodoriDbSource } from './holodoridb-source.mjs';
|
||||||
|
import { susScoreWeight } from './sus-score-weight.mjs';
|
||||||
|
|
||||||
const OCTO_API_KEY = 'B46OtKlGGHoz6sxbOWDe3VUvBsagXxr5av38IQIKUKo=';
|
const OCTO_API_KEY = 'B46OtKlGGHoz6sxbOWDe3VUvBsagXxr5av38IQIKUKo=';
|
||||||
const OCTO_CLIENT_KEY = 'CwFhQ+S5m4nERWVaq5oFZIP0cZLc0j7O/zllG0UYVNo=';
|
const OCTO_CLIENT_KEY = 'CwFhQ+S5m4nERWVaq5oFZIP0cZLc0j7O/zllG0UYVNo=';
|
||||||
@@ -94,7 +96,13 @@ function decryptResource(bytes, name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requested = process.argv.slice(2);
|
const requested = process.argv.slice(2);
|
||||||
if (requested.length === 0) throw new Error('SUS 파일명 또는 곡 ID를 지정하세요. 예: m0344 m0348 m0527');
|
if (requested.length === 0) throw new Error('SUS 파일명, 곡 ID 또는 --all을 지정하세요.');
|
||||||
|
const collectAll = requested.length === 1 && requested[0] === '--all';
|
||||||
|
if (!collectAll && requested.some((value) => !/^m\d{4}$|^chart_m\d{4}_(easy|normal|hard|expert)\.sus$/.test(value))) {
|
||||||
|
throw new Error('잘못된 SUS 수집 대상입니다.');
|
||||||
|
}
|
||||||
|
// Pin every reviewed locale before downloading. Never use stale refs after a failed fetch.
|
||||||
|
const sources = Object.fromEntries(['eng', 'kor', 'jpn'].map((locale) => [locale, prepareHolodoriDbSource(locale)]));
|
||||||
const requestedNames = new Set(requested.flatMap((value) =>
|
const requestedNames = new Set(requested.flatMap((value) =>
|
||||||
/^m\d{4}$/.test(value) ? ['easy', 'normal', 'hard', 'expert'].map((difficulty) => `chart_${value}_${difficulty}.sus`) : [value]
|
/^m\d{4}$/.test(value) ? ['easy', 'normal', 'hard', 'expert'].map((difficulty) => `chart_${value}_${difficulty}.sus`) : [value]
|
||||||
));
|
));
|
||||||
@@ -102,20 +110,48 @@ const requestedNames = new Set(requested.flatMap((value) =>
|
|||||||
const catalogResponse = await fetch(OCTO_LIST_URL, { headers: { 'X-OCTO-KEY': OCTO_API_KEY, 'X-APP-OCTO-KEY': OCTO_CLIENT_KEY } });
|
const catalogResponse = await fetch(OCTO_LIST_URL, { headers: { 'X-OCTO-KEY': OCTO_API_KEY, 'X-APP-OCTO-KEY': OCTO_CLIENT_KEY } });
|
||||||
if (!catalogResponse.ok) throw new Error(`Octo 카탈로그 요청 실패: ${catalogResponse.status}`);
|
if (!catalogResponse.ok) throw new Error(`Octo 카탈로그 요청 실패: ${catalogResponse.status}`);
|
||||||
const catalog = decryptCatalog(Buffer.from(await catalogResponse.arrayBuffer()));
|
const catalog = decryptCatalog(Buffer.from(await catalogResponse.arrayBuffer()));
|
||||||
const selected = catalog.entries.filter((entry) => requestedNames.has(entry.name));
|
const selected = catalog.entries.filter((entry) => collectAll ? /^chart_m\d{4}_(easy|normal|hard|expert)\.sus$/.test(entry.name) : requestedNames.has(entry.name));
|
||||||
if (selected.length !== requestedNames.size) {
|
if (!collectAll && selected.length !== requestedNames.size) {
|
||||||
const found = new Set(selected.map((entry) => entry.name));
|
const found = new Set(selected.map((entry) => entry.name));
|
||||||
throw new Error(`카탈로그에 없는 SUS: ${[...requestedNames].filter((name) => !found.has(name)).join(', ')}`);
|
throw new Error(`카탈로그에 없는 SUS: ${[...requestedNames].filter((name) => !found.has(name)).join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputDirectory = path.resolve('data/raw/sus');
|
const outputDirectory = path.resolve('data/raw/sus');
|
||||||
await mkdir(outputDirectory, { recursive: true });
|
await mkdir(outputDirectory, { recursive: true });
|
||||||
for (const entry of selected.sort((left, right) => left.name.localeCompare(right.name))) {
|
if (!selected.length || new Set(selected.map((entry) => entry.name)).size !== selected.length) throw new Error('비어 있거나 중복된 SUS 카탈로그입니다.');
|
||||||
const response = await fetch(catalog.urlFormat.replace('{o}', entry.objectName));
|
const results = [];
|
||||||
if (!response.ok) throw new Error(`${entry.name} 다운로드 실패: ${response.status}`);
|
let cursor = 0;
|
||||||
const plaintext = decryptResource(Buffer.from(await response.arrayBuffer()), entry.name);
|
console.log(`Octo revision ${catalog.revisionId}: ${selected.length}개 SUS 수집 시작`);
|
||||||
if (!plaintext.toString('utf8', 0, 9).startsWith('#CREATE_')) throw new Error(`${entry.name} 복호화 결과가 SUS가 아닙니다.`);
|
async function worker() {
|
||||||
await writeFile(path.join(outputDirectory, entry.name), plaintext);
|
while (cursor < selected.length) {
|
||||||
console.log(`${entry.name} ${createHash('sha256').update(plaintext).digest('hex')}`);
|
const entry = selected[cursor++];
|
||||||
|
const url = catalog.urlFormat.replace('{o}', entry.objectName);
|
||||||
|
let error;
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(120000) });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const plaintext = decryptResource(Buffer.from(await response.arrayBuffer()), entry.name);
|
||||||
|
const text = plaintext.toString('utf8');
|
||||||
|
if (!text.startsWith('#CREATE_')) throw new Error('복호화 결과가 SUS가 아닙니다.');
|
||||||
|
const songId = text.match(/^#MUSIC_ID\s+(\S+)/m)?.[1];
|
||||||
|
if (songId !== entry.name.split('_')[1]) throw new Error('SUS 곡 ID가 파일명과 다릅니다.');
|
||||||
|
let weight = null;
|
||||||
|
let validationError = null;
|
||||||
|
try { weight = susScoreWeight(text); } catch (validation) { validationError = validation.message; }
|
||||||
|
await writeFile(path.join(outputDirectory, entry.name), plaintext);
|
||||||
|
results.push({ name: entry.name, url, bytes: plaintext.length, sha256: createHash('sha256').update(plaintext).digest('hex'), weight, validationError });
|
||||||
|
error = null;
|
||||||
|
break;
|
||||||
|
} catch (failure) { error = failure; }
|
||||||
|
}
|
||||||
|
if (error) results.push({ name: entry.name, url, error: error.message });
|
||||||
|
if (results.length % 50 === 0 || results.length === selected.length) console.log(`${results.length}/${selected.length}개 처리`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log(`Octo revision ${catalog.revisionId}: ${selected.length}개 SUS 저장 완료`);
|
await Promise.all(Array.from({ length: 6 }, worker));
|
||||||
|
results.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
await writeFile(path.join(outputDirectory, collectAll ? 'collection-manifest.json' : 'selected-collection-manifest.json'), JSON.stringify({ collectedAt: new Date().toISOString(), revisionId: catalog.revisionId, sources, files: results }, null, 2));
|
||||||
|
const failed = results.filter((result) => result.error);
|
||||||
|
if (failed.length) throw new Error(`SUS ${failed.length}개 수집 실패: ${failed.map((result) => result.name).join(', ')}`);
|
||||||
|
console.log(`Octo revision ${catalog.revisionId}: ${results.length}개 SUS 저장 완료, 점수 구조 검증 경고 ${results.filter((result) => result.validationError).length}개`);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const report = JSON.parse(await readFile('data/wf-audit/latest.json','utf8'));
|
||||||
|
const audit = await readFile('scripts/audit-wf-charts.mjs','utf8');
|
||||||
|
const context = vm.createContext({});
|
||||||
|
vm.runInContext(audit.slice(audit.indexOf('const counts ='),audit.indexOf('async function worker()')),context);
|
||||||
|
const simulator = await readFile('data/wf-audit/simulator.js','utf8');
|
||||||
|
const catalogText=simulator.match(/\b\w+=JSON\.parse\(`(\[\{"key":"m[\s\S]*?)`\)/)?.[1];
|
||||||
|
if(!catalogText)throw new Error('wf chart catalog not found');
|
||||||
|
const catalog = JSON.parse(catalogText.replaceAll('\\\\','\\').replaceAll('\\`','`').replaceAll('\\${','${'));
|
||||||
|
const collection=JSON.parse(await readFile('data/raw/sus/collection-manifest.json','utf8'));
|
||||||
|
for(const row of report.rows) {
|
||||||
|
const data=JSON.parse(await readFile(`data/wf-audit/charts/${row.songId}.${row.difficulty}.json`,'utf8'));
|
||||||
|
const local=JSON.parse(await readFile(`static/data/charts/${row.songId}.${row.difficulty}.json`,'utf8'));
|
||||||
|
const events=data.note_events;
|
||||||
|
// Match pn() in the saved wf simulator: preserve its explicit note_type/is_flick.
|
||||||
|
const native=[...(events.blue??[]).map(n=>({...n,type:'regular',isFlick:n.is_flick??false})),
|
||||||
|
...(events.pink??[]).map(n=>({...n,type:'regular',isFlick:n.is_flick??true})),
|
||||||
|
...(events.green_blocks??[]).flatMap(block=>block.map((n,i)=>({...n,type:n.note_type??(i===0||i===block.length-1?'regular':'mid'),isFlick:n.is_flick??false})))
|
||||||
|
].map(n=>({timeSeconds:n.time_seconds,type:n.type,isFlick:n.isFlick}));
|
||||||
|
context.left=local.notes;context.right=native;
|
||||||
|
row.localVsWfNative=vm.runInContext('compare(left,right)',context);
|
||||||
|
row.wfNativeCounts=vm.runInContext('counts(right)',context);
|
||||||
|
row.wfNativeCategoryMatchesSus=Object.keys(row.susExpected).every(k=>row.susExpected[k]===row.wfNativeCounts[k]);
|
||||||
|
row.wfSourceMatchesSus=row.wfSource.sha256===collection.files.find(f=>f.name===`chart_${row.songId}_${row.difficulty}.sus`).sha256;
|
||||||
|
const metadata=catalog.find(c=>c.key===`${row.songId}::${row.difficulty.toUpperCase()}`);
|
||||||
|
row.ratio={local:local.scoreRatio,wfCatalog:metadata?.scoreRatio,wfCatalogSource:metadata?.scoreCalibrationSource};
|
||||||
|
}
|
||||||
|
report.summary.wfNativeCategoryMismatchSus=report.rows.filter(r=>!r.wfNativeCategoryMatchesSus).length;
|
||||||
|
report.summary.localVsWfNativeMatches=report.rows.filter(r=>!r.localVsWfNative.extra&&!r.localVsWfNative.missing).length;
|
||||||
|
report.summary.wfSourceHashMismatchSus=report.rows.filter(r=>!r.wfSourceMatchesSus).length;
|
||||||
|
await writeFile('data/wf-audit/latest.json',JSON.stringify(report,null,2));
|
||||||
|
const lines=['# wf-calc 전체 채보 비교','',`비교 시각: ${report.generatedAt}`,`스냅샷: ${report.simulatorUrl}`,'',
|
||||||
|
'## 해석','',
|
||||||
|
'- wf 원본: 사이트의 pn() 함수처럼 note_type/is_flick을 그대로 읽은 데이터입니다. 사이트의 최종 점수 엔진 전체를 재현했다는 뜻은 아닙니다.',
|
||||||
|
'- 우리 어댑터: 기존 수집기의 flattenEvents를 거친 결과입니다. 이 단계의 분류 차이를 wf 자체 오류라고 부르면 안 됩니다.',
|
||||||
|
'- 시간 비교 허용치는 1마이크로초이며 동시 노트는 개수까지 비교합니다.',
|
||||||
|
'- wf 카탈로그 scoreRatio는 estimated 등 출처가 표시된 카탈로그 값입니다. 사이트에서 최종 계산 시 사용하는 값과 같은지는 별도 검증이 필요합니다.',
|
||||||
|
'- 원본 SUS 헤더는 노트 종류별 개수와 분모 검증에 사용했습니다. 기존 SUS 타이밍 재구성 후보의 미해결 사항은 그대로 남아 있습니다.','',
|
||||||
|
'## 집계','','```json',JSON.stringify(report.summary,null,2),'```','',
|
||||||
|
'## Mekurumeku Rendezvous Expert','','```json',JSON.stringify(report.rows.find(r=>r.songId==='m0527'&&r.difficulty==='expert'),null,2),'```','',
|
||||||
|
'## 전곡 결과','','|곡|난이도|우리 어댑터 적용 후 차이 초과/누락|wf 원본 분류 차이 초과/누락|스페셜 차이|wf 원본 해시 변경|', '|---|---|---|---|---|---|'];
|
||||||
|
for(const r of report.rows)lines.push(`|${r.title}|${r.difficulty}|${r.localVsWf.extra}/${r.localVsWf.missing}|${r.localVsWfNative.extra}/${r.localVsWfNative.missing}|${r.localSpecialMismatch}|${!r.wfSourceMatchesSus}|`);
|
||||||
|
await writeFile('data/wf-audit/latest.md',lines.join('\n')+'\n');
|
||||||
|
console.log(JSON.stringify(report.summary,null,2));
|
||||||
|
console.log(JSON.stringify(report.rows.find(r=>r.songId==='m0527'&&r.difficulty==='expert'),null,2));
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
|
||||||
|
function charValue(character) {
|
||||||
|
const value = alphabet.indexOf(character);
|
||||||
|
if (value < 0) throw new Error(`잘못된 SUS base62 값: ${character}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCells(data, includeZero = false) {
|
||||||
|
const cells = [];
|
||||||
|
let position = 0;
|
||||||
|
for (let index = 0; index < data.length;) {
|
||||||
|
if (data.startsWith('0x', index)) {
|
||||||
|
position += charValue(data[index + 2]) * 62 + charValue(data[index + 3]);
|
||||||
|
index += 4;
|
||||||
|
} else {
|
||||||
|
const cell = data.slice(index, index + 2);
|
||||||
|
if (cell.length !== 2) throw new Error('잘린 SUS 셀입니다.');
|
||||||
|
if (includeZero ? cell !== '00' : cell[0] !== '0') cells.push({ position, cell });
|
||||||
|
position += 1;
|
||||||
|
index += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { cells, total: position };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeaders(text) {
|
||||||
|
const headers = new Map();
|
||||||
|
for (const raw of text.split(/\r?\n/)) {
|
||||||
|
const match = raw.match(/^#([A-Z_]+)\s+(.+)$/);
|
||||||
|
if (match) headers.set(match[1], match[2].trim().replaceAll('"', ''));
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureStarts(lengths) {
|
||||||
|
const starts = [];
|
||||||
|
let beat = 0;
|
||||||
|
for (let measure = 0; measure < 1000; measure += 1) {
|
||||||
|
starts[measure] = beat;
|
||||||
|
beat += lengths.get(measure) ?? 4;
|
||||||
|
}
|
||||||
|
return starts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSusTimeline(text) {
|
||||||
|
const headers = parseHeaders(text);
|
||||||
|
const lengths = new Map();
|
||||||
|
const bpmDefinitions = new Map();
|
||||||
|
const lines = text.split(/\r?\n/).map((raw) => raw.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
const bar = line.match(/^#(\d{3})02:(.+)$/);
|
||||||
|
if (bar) lengths.set(Number(bar[1]), Number(bar[2]));
|
||||||
|
const bpm = line.match(/^#BPM(..):(.+)$/);
|
||||||
|
if (bpm) bpmDefinitions.set(bpm[1], Number(bpm[2]));
|
||||||
|
}
|
||||||
|
// A measure-length change persists until the next directive.
|
||||||
|
let currentLength = 4;
|
||||||
|
for (let measure = 0; measure < 1000; measure += 1) {
|
||||||
|
currentLength = lengths.get(measure) ?? currentLength;
|
||||||
|
lengths.set(measure, currentLength);
|
||||||
|
}
|
||||||
|
const starts = measureStarts(lengths);
|
||||||
|
const toBeat = (measure, position, total) => starts[measure] + (lengths.get(measure) ?? 4) * position / total;
|
||||||
|
const bpmEvents = [];
|
||||||
|
const taps = [];
|
||||||
|
const flicks = [];
|
||||||
|
const holdStreams = new Map();
|
||||||
|
const visibleRelays = [];
|
||||||
|
const specialStarts = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const holdMatch = line.match(/^#(\d{3})3([0-9a-z])([0-9a-zA-Z]):(.+)$/);
|
||||||
|
if (holdMatch) {
|
||||||
|
const [, measureText, lane, channel, data] = holdMatch;
|
||||||
|
const measure = Number(measureText);
|
||||||
|
const parsed = parseCells(data);
|
||||||
|
const notes = parsed.cells.map(({ position, cell }) => ({ beat: toBeat(measure, position, parsed.total), lane: charValue(lane), kind: charValue(cell[0]), width: charValue(cell[1]) }));
|
||||||
|
visibleRelays.push(...notes.filter((note) => note.kind === 3));
|
||||||
|
const stream = charValue(channel);
|
||||||
|
if (!holdStreams.has(stream)) holdStreams.set(stream, []);
|
||||||
|
holdStreams.get(stream).push(...notes);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = line.match(/^#(\d{3})([0-9a-z])([0-9a-zA-Z]):(.+)$/);
|
||||||
|
if (!match) continue;
|
||||||
|
const [, measureText, family, lane, data] = match;
|
||||||
|
const measure = Number(measureText);
|
||||||
|
if (`${family}${lane}` === '08') {
|
||||||
|
for (const { position, cell } of parseCells(data, true).cells) bpmEvents.push({ beat: toBeat(measure, position, parseCells(data, true).total), bpm: bpmDefinitions.get(cell) ?? Number(headers.get('BASEBPM') ?? 120) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (`${family}${lane}` === '0B') {
|
||||||
|
for (const { position } of parseCells(data, true).cells) specialStarts.push(toBeat(measure, position, parseCells(data, true).total));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!['1', '5'].includes(family)) continue;
|
||||||
|
const parsed = parseCells(data);
|
||||||
|
const notes = parsed.cells.map(({ position, cell }) => ({ beat: toBeat(measure, position, parsed.total), lane: charValue(lane), kind: charValue(cell[0]), width: charValue(cell[1]) }));
|
||||||
|
if (family === '1') taps.push(...notes);
|
||||||
|
else if (family === '5') flicks.push(...notes);
|
||||||
|
}
|
||||||
|
if (bpmEvents.length === 0) bpmEvents.push({ beat: 0, bpm: Number(headers.get('BASEBPM') ?? 120) });
|
||||||
|
bpmEvents.sort((left, right) => left.beat - right.beat);
|
||||||
|
const uniqueBpmEvents = bpmEvents.filter((event, index) => index === 0 || event.beat !== bpmEvents[index - 1].beat);
|
||||||
|
function secondsAt(beat) {
|
||||||
|
let seconds = 0;
|
||||||
|
let previous = uniqueBpmEvents[0];
|
||||||
|
if (previous.beat > 0) seconds += previous.beat * 60 / Number(headers.get('BASEBPM') ?? 120);
|
||||||
|
for (let index = 1; index < uniqueBpmEvents.length && uniqueBpmEvents[index].beat <= beat; index += 1) {
|
||||||
|
const event = uniqueBpmEvents[index];
|
||||||
|
seconds += (event.beat - previous.beat) * 60 / previous.bpm;
|
||||||
|
previous = event;
|
||||||
|
}
|
||||||
|
seconds += (beat - previous.beat) * 60 / previous.bpm;
|
||||||
|
return seconds + Number(headers.get('WAVEOFFSET') ?? 0);
|
||||||
|
}
|
||||||
|
const key = (note) => `${note.beat}|${note.lane}`;
|
||||||
|
const tapKeys = new Set(taps.filter((note) => note.kind === 1 || note.kind === 2).map(key));
|
||||||
|
const standaloneFlickKeys = new Set(flicks.filter((note) => [1, 3, 4].includes(note.kind)).map(key));
|
||||||
|
const holds = [];
|
||||||
|
for (const stream of holdStreams.values()) {
|
||||||
|
const deduped = [...new Map(stream.sort((left, right) => left.beat - right.beat).map((note) => [`${note.beat}|${note.lane}|${note.kind}|${note.width}`, note])).values()];
|
||||||
|
const open = [];
|
||||||
|
const relays = [];
|
||||||
|
for (const note of deduped) {
|
||||||
|
if (note.kind === 1) open.push(note);
|
||||||
|
else if (note.kind === 2) {
|
||||||
|
const index = open.findIndex((start) => start.beat <= note.beat);
|
||||||
|
if (index < 0) continue;
|
||||||
|
const start = open.splice(index, 1)[0];
|
||||||
|
const inner = relays.filter((relay) => start.beat <= relay.beat && relay.beat <= note.beat);
|
||||||
|
for (const relay of inner) relays.splice(relays.indexOf(relay), 1);
|
||||||
|
holds.push([start, ...inner.sort((left, right) => left.beat - right.beat), note]);
|
||||||
|
} else relays.push(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const holdStartKeys = new Set(holds.map((hold) => key(hold[0])));
|
||||||
|
const holdEndKeys = new Set(holds.map((hold) => key(hold.at(-1))));
|
||||||
|
const notes = [];
|
||||||
|
// A co-located directional is one flick. A hold end directional is part of the hold and scores at normal weight.
|
||||||
|
for (const tap of taps) {
|
||||||
|
if (![1, 2].includes(tap.kind) || holdStartKeys.has(key(tap)) || holdEndKeys.has(key(tap))) continue;
|
||||||
|
notes.push({ timeSeconds: secondsAt(tap.beat), type: 'regular', isFlick: standaloneFlickKeys.has(key(tap)) });
|
||||||
|
}
|
||||||
|
for (const flick of flicks) {
|
||||||
|
if (![1, 3, 4].includes(flick.kind) || tapKeys.has(key(flick)) || holdStartKeys.has(key(flick)) || holdEndKeys.has(key(flick))) continue;
|
||||||
|
notes.push({ timeSeconds: secondsAt(flick.beat), type: 'regular', isFlick: true });
|
||||||
|
}
|
||||||
|
for (const hold of holds) {
|
||||||
|
const start = hold[0];
|
||||||
|
const end = hold.at(-1);
|
||||||
|
notes.push({ timeSeconds: secondsAt(start.beat), type: 'regular', isFlick: false });
|
||||||
|
// The game creates a continuation judgement every half beat. A visible relay at the same
|
||||||
|
// position remains a separate score judgement.
|
||||||
|
// Continuations lie on the song's half-beat grid, not half a beat after the hold head.
|
||||||
|
for (let beat = (Math.floor(start.beat * 2 + 1e-9) + 1) / 2; beat < end.beat - 1e-9; beat += 0.5) {
|
||||||
|
notes.push({ timeSeconds: secondsAt(beat), type: 'mid', isFlick: false });
|
||||||
|
}
|
||||||
|
notes.push({ timeSeconds: secondsAt(end.beat), type: 'regular', isFlick: false });
|
||||||
|
}
|
||||||
|
// Visible relay ticks are score judgements even if the editor stream contains an unmatched visual segment.
|
||||||
|
for (const relay of visibleRelays) notes.push({ timeSeconds: secondsAt(relay.beat), type: 'mid', isFlick: false });
|
||||||
|
notes.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
|
||||||
|
const expectedRegular = Number(headers.get('NORMAL_NOTE_COUNT')) + Number(headers.get('FLICK_NOTE_COUNT')) +
|
||||||
|
Number(headers.get('LONG_START_NOTE_COUNT')) + Number(headers.get('LONG_END_NOTE_COUNT')) + Number(headers.get('LONG_FLICK_END_NOTE_COUNT'));
|
||||||
|
const expectedMid = Number(headers.get('LONG_RELAY_NOTE_COUNT')) + Number(headers.get('LONG_CONTINUE_NOTE_COUNT'));
|
||||||
|
const regularCount = notes.filter((note) => note.type === 'regular').length;
|
||||||
|
const midCount = notes.length - regularCount;
|
||||||
|
const flickCount = notes.filter((note) => note.isFlick).length;
|
||||||
|
if (regularCount !== expectedRegular || midCount !== expectedMid || flickCount !== Number(headers.get('FLICK_NOTE_COUNT'))) {
|
||||||
|
throw new Error(`${headers.get('MUSIC_ID')}: SUS category mismatch: regular ${regularCount}/${expectedRegular}, mid ${midCount}/${expectedMid}, flick ${flickCount}/${headers.get('FLICK_NOTE_COUNT')}`);
|
||||||
|
}
|
||||||
|
notes.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
|
||||||
|
return { songId: headers.get('MUSIC_ID'), notes, specialStartSeconds: specialStarts.sort((left, right) => left - right).map(secondsAt) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseSusTimeline } from './sus-timeline.mjs';
|
||||||
|
import { susScoreWeight } from './sus-score-weight.mjs';
|
||||||
|
|
||||||
|
function grid(points) {
|
||||||
|
const cells = Array(16).fill('00');
|
||||||
|
for (const [index, value] of points) cells[index] = value;
|
||||||
|
return cells.join('');
|
||||||
|
}
|
||||||
|
const fixture = `#MUSIC_ID m0001
|
||||||
|
#BASEBPM 60
|
||||||
|
#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
|
||||||
|
#GHOST_NOTE_COUNT 0
|
||||||
|
#DAMAGE_NOTE_COUNT 0
|
||||||
|
#00002:4
|
||||||
|
#000320:${grid([[1,'13'],[3,'33'],[4,'53'],[5,'23']])}
|
||||||
|
#00012:${grid([[5,'13'],[15,'13']])}
|
||||||
|
#00052:${grid([[5,'13'],[8,'13']])}
|
||||||
|
`;
|
||||||
|
|
||||||
|
test('끝 플릭을 중복 집계하지 않고 중간 판정을 곡의 반박 격자에 배치한다', () => {
|
||||||
|
const { notes } = parseSusTimeline(fixture);
|
||||||
|
assert.deepEqual(notes, [
|
||||||
|
{ timeSeconds:0.25,type:'regular',isFlick:false },
|
||||||
|
{ timeSeconds:0.5,type:'mid',isFlick:false },
|
||||||
|
{ timeSeconds:0.75,type:'mid',isFlick:false },
|
||||||
|
{ timeSeconds:1,type:'mid',isFlick:false },
|
||||||
|
{ timeSeconds:1.25,type:'regular',isFlick:false },
|
||||||
|
{ timeSeconds:2,type:'regular',isFlick:true },
|
||||||
|
{ timeSeconds:3.75,type:'regular',isFlick:false }
|
||||||
|
]);
|
||||||
|
// Song Mechanics: hidden relay contributes only to the denominator.
|
||||||
|
assert.equal(susScoreWeight(fixture).perfectNoteWeight, 4450);
|
||||||
|
assert.equal(notes.reduce((sum,n)=>sum+(n.type==='mid'?100:n.isFlick?1050:1000),0),4350);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('헤더 불일치를 후반 삭제나 임의 중간 노트 추가로 숨기지 않는다', () => {
|
||||||
|
assert.throws(()=>parseSusTimeline(fixture.replace('#NORMAL_NOTE_COUNT 1','#NORMAL_NOTE_COUNT 0')), /category mismatch/);
|
||||||
|
assert.throws(()=>parseSusTimeline(fixture.replace('#LONG_CONTINUE_NOTE_COUNT 2','#LONG_CONTINUE_NOTE_COUNT 3')), /category mismatch/);
|
||||||
|
assert.throws(()=>parseSusTimeline(fixture.replace('#FLICK_NOTE_COUNT 1','#FLICK_NOTE_COUNT 0')), /category mismatch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('마디 길이 변경은 다음 변경까지 유지한다', () => {
|
||||||
|
const text = `#MUSIC_ID m0002
|
||||||
|
#BASEBPM 60
|
||||||
|
#NORMAL_NOTE_COUNT 2
|
||||||
|
#FLICK_NOTE_COUNT 0
|
||||||
|
#LONG_START_NOTE_COUNT 0
|
||||||
|
#LONG_END_NOTE_COUNT 0
|
||||||
|
#LONG_FLICK_END_NOTE_COUNT 0
|
||||||
|
#LONG_RELAY_NOTE_COUNT 0
|
||||||
|
#LONG_CONTINUE_NOTE_COUNT 0
|
||||||
|
#00002:3
|
||||||
|
#00112:11
|
||||||
|
#00212:11
|
||||||
|
`;
|
||||||
|
assert.deepEqual(parseSusTimeline(text).notes.map(n=>n.timeSeconds),[3,6]);
|
||||||
|
});
|
||||||
+13
-178
@@ -6,189 +6,22 @@ import path from 'node:path';
|
|||||||
import { susScoreWeight } from './sus-score-weight.mjs';
|
import { susScoreWeight } from './sus-score-weight.mjs';
|
||||||
import { formulaScoreMetadata } from './song-score-formulas.mjs';
|
import { formulaScoreMetadata } from './song-score-formulas.mjs';
|
||||||
|
|
||||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
import { parseSusTimeline } from './sus-timeline.mjs';
|
||||||
const difficulties = ['easy', 'normal', 'hard', 'expert'];
|
const difficulties = ['easy', 'normal', 'hard', 'expert'];
|
||||||
|
|
||||||
function charValue(character) {
|
|
||||||
const value = alphabet.indexOf(character);
|
|
||||||
if (value < 0) throw new Error(`잘못된 SUS base62 값: ${character}`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCells(data, includeZero = false) {
|
|
||||||
const cells = [];
|
|
||||||
let position = 0;
|
|
||||||
for (let index = 0; index < data.length;) {
|
|
||||||
if (data.startsWith('0x', index)) {
|
|
||||||
position += charValue(data[index + 2]) * 62 + charValue(data[index + 3]);
|
|
||||||
index += 4;
|
|
||||||
} else {
|
|
||||||
const cell = data.slice(index, index + 2);
|
|
||||||
if (cell.length !== 2) throw new Error('잘린 SUS 셀입니다.');
|
|
||||||
if (includeZero ? cell !== '00' : cell[0] !== '0') cells.push({ position, cell });
|
|
||||||
position += 1;
|
|
||||||
index += 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { cells, total: position };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseHeaders(text) {
|
|
||||||
const headers = new Map();
|
|
||||||
for (const raw of text.split(/\r?\n/)) {
|
|
||||||
const match = raw.match(/^#([A-Z_]+)\s+(.+)$/);
|
|
||||||
if (match) headers.set(match[1], match[2].trim().replaceAll('"', ''));
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
function measureStarts(lengths) {
|
|
||||||
const starts = [];
|
|
||||||
let beat = 0;
|
|
||||||
for (let measure = 0; measure < 1000; measure += 1) {
|
|
||||||
starts[measure] = beat;
|
|
||||||
beat += lengths.get(measure) ?? 4;
|
|
||||||
}
|
|
||||||
return starts;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSusTimeline(text) {
|
|
||||||
const headers = parseHeaders(text);
|
|
||||||
const lengths = new Map();
|
|
||||||
const bpmDefinitions = new Map();
|
|
||||||
const lines = text.split(/\r?\n/).map((raw) => raw.trim());
|
|
||||||
for (const line of lines) {
|
|
||||||
const bar = line.match(/^#(\d{3})02:(.+)$/);
|
|
||||||
if (bar) lengths.set(Number(bar[1]), Number(bar[2]));
|
|
||||||
const bpm = line.match(/^#BPM(..):(.+)$/);
|
|
||||||
if (bpm) bpmDefinitions.set(bpm[1], Number(bpm[2]));
|
|
||||||
}
|
|
||||||
const starts = measureStarts(lengths);
|
|
||||||
const toBeat = (measure, position, total) => starts[measure] + (lengths.get(measure) ?? 4) * position / total;
|
|
||||||
const bpmEvents = [];
|
|
||||||
const taps = [];
|
|
||||||
const flicks = [];
|
|
||||||
const holdStreams = new Map();
|
|
||||||
const visibleRelays = [];
|
|
||||||
const specialStarts = [];
|
|
||||||
for (const line of lines) {
|
|
||||||
const holdMatch = line.match(/^#(\d{3})3([0-9a-z])([0-9a-zA-Z]):(.+)$/);
|
|
||||||
if (holdMatch) {
|
|
||||||
const [, measureText, lane, channel, data] = holdMatch;
|
|
||||||
const measure = Number(measureText);
|
|
||||||
const parsed = parseCells(data);
|
|
||||||
const notes = parsed.cells.map(({ position, cell }) => ({ beat: toBeat(measure, position, parsed.total), lane: charValue(lane), kind: charValue(cell[0]), width: charValue(cell[1]) }));
|
|
||||||
visibleRelays.push(...notes.filter((note) => note.kind === 3));
|
|
||||||
const stream = charValue(channel);
|
|
||||||
if (!holdStreams.has(stream)) holdStreams.set(stream, []);
|
|
||||||
holdStreams.get(stream).push(...notes);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const match = line.match(/^#(\d{3})([0-9a-z])([0-9a-zA-Z]):(.+)$/);
|
|
||||||
if (!match) continue;
|
|
||||||
const [, measureText, family, lane, data] = match;
|
|
||||||
const measure = Number(measureText);
|
|
||||||
if (`${family}${lane}` === '08') {
|
|
||||||
for (const { position, cell } of parseCells(data, true).cells) bpmEvents.push({ beat: toBeat(measure, position, parseCells(data, true).total), bpm: bpmDefinitions.get(cell) ?? Number(headers.get('BASEBPM') ?? 120) });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (`${family}${lane}` === '0B') {
|
|
||||||
for (const { position } of parseCells(data, true).cells) specialStarts.push(toBeat(measure, position, parseCells(data, true).total));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!['1', '5'].includes(family)) continue;
|
|
||||||
const parsed = parseCells(data);
|
|
||||||
const notes = parsed.cells.map(({ position, cell }) => ({ beat: toBeat(measure, position, parsed.total), lane: charValue(lane), kind: charValue(cell[0]), width: charValue(cell[1]) }));
|
|
||||||
if (family === '1') taps.push(...notes);
|
|
||||||
else if (family === '5') flicks.push(...notes);
|
|
||||||
}
|
|
||||||
if (bpmEvents.length === 0) bpmEvents.push({ beat: 0, bpm: Number(headers.get('BASEBPM') ?? 120) });
|
|
||||||
bpmEvents.sort((left, right) => left.beat - right.beat);
|
|
||||||
const uniqueBpmEvents = bpmEvents.filter((event, index) => index === 0 || event.beat !== bpmEvents[index - 1].beat);
|
|
||||||
function secondsAt(beat) {
|
|
||||||
let seconds = 0;
|
|
||||||
let previous = uniqueBpmEvents[0];
|
|
||||||
if (previous.beat > 0) seconds += previous.beat * 60 / Number(headers.get('BASEBPM') ?? 120);
|
|
||||||
for (let index = 1; index < uniqueBpmEvents.length && uniqueBpmEvents[index].beat <= beat; index += 1) {
|
|
||||||
const event = uniqueBpmEvents[index];
|
|
||||||
seconds += (event.beat - previous.beat) * 60 / previous.bpm;
|
|
||||||
previous = event;
|
|
||||||
}
|
|
||||||
seconds += (beat - previous.beat) * 60 / previous.bpm;
|
|
||||||
return seconds + Number(headers.get('WAVEOFFSET') ?? 0);
|
|
||||||
}
|
|
||||||
const key = (note) => `${note.beat}|${note.lane}`;
|
|
||||||
const tapKeys = new Set(taps.filter((note) => note.kind === 1 || note.kind === 2).map(key));
|
|
||||||
const standaloneFlickKeys = new Set(flicks.filter((note) => [1, 3, 4].includes(note.kind)).map(key));
|
|
||||||
const holds = [];
|
|
||||||
for (const stream of holdStreams.values()) {
|
|
||||||
const deduped = [...new Map(stream.sort((left, right) => left.beat - right.beat).map((note) => [`${note.beat}|${note.lane}|${note.kind}|${note.width}`, note])).values()];
|
|
||||||
const open = [];
|
|
||||||
const relays = [];
|
|
||||||
for (const note of deduped) {
|
|
||||||
if (note.kind === 1) open.push(note);
|
|
||||||
else if (note.kind === 2) {
|
|
||||||
const index = open.findIndex((start) => start.beat <= note.beat);
|
|
||||||
if (index < 0) continue;
|
|
||||||
const start = open.splice(index, 1)[0];
|
|
||||||
const inner = relays.filter((relay) => start.beat <= relay.beat && relay.beat <= note.beat);
|
|
||||||
for (const relay of inner) relays.splice(relays.indexOf(relay), 1);
|
|
||||||
holds.push([start, ...inner.sort((left, right) => left.beat - right.beat), note]);
|
|
||||||
} else relays.push(note);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const holdStartKeys = new Set(holds.map((hold) => key(hold[0])));
|
|
||||||
const holdEndKeys = new Set(holds.map((hold) => key(hold.at(-1))));
|
|
||||||
const notes = [];
|
|
||||||
// A co-located directional is one flick. A hold end directional is part of the hold and scores at normal weight.
|
|
||||||
for (const tap of taps) {
|
|
||||||
if (![1, 2].includes(tap.kind) || holdStartKeys.has(key(tap))) continue;
|
|
||||||
notes.push({ timeSeconds: secondsAt(tap.beat), type: 'regular', isFlick: standaloneFlickKeys.has(key(tap)) });
|
|
||||||
}
|
|
||||||
for (const flick of flicks) {
|
|
||||||
if (![1, 3, 4].includes(flick.kind) || tapKeys.has(key(flick)) || holdStartKeys.has(key(flick)) || holdEndKeys.has(key(flick))) continue;
|
|
||||||
notes.push({ timeSeconds: secondsAt(flick.beat), type: 'regular', isFlick: true });
|
|
||||||
}
|
|
||||||
for (const hold of holds) {
|
|
||||||
const start = hold[0];
|
|
||||||
const end = hold.at(-1);
|
|
||||||
notes.push({ timeSeconds: secondsAt(start.beat), type: 'regular', isFlick: false });
|
|
||||||
// The game creates a continuation judgement every half beat. A visible relay at the same
|
|
||||||
// position remains a separate score judgement.
|
|
||||||
for (let beat = start.beat + 0.5; beat < end.beat - 1e-9; beat += 0.5) {
|
|
||||||
notes.push({ timeSeconds: secondsAt(beat), type: 'mid', isFlick: false });
|
|
||||||
}
|
|
||||||
notes.push({ timeSeconds: secondsAt(end.beat), type: 'regular', isFlick: false });
|
|
||||||
}
|
|
||||||
// Visible relay ticks are score judgements even if the editor stream contains an unmatched visual segment.
|
|
||||||
for (const relay of visibleRelays) notes.push({ timeSeconds: secondsAt(relay.beat), type: 'mid', isFlick: false });
|
|
||||||
notes.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
|
|
||||||
const expectedRegular = Number(headers.get('NORMAL_NOTE_COUNT')) + Number(headers.get('FLICK_NOTE_COUNT')) +
|
|
||||||
Number(headers.get('LONG_START_NOTE_COUNT')) + Number(headers.get('LONG_END_NOTE_COUNT')) + Number(headers.get('LONG_FLICK_END_NOTE_COUNT'));
|
|
||||||
// SUS may encode a directional overlay as a second event at the exact same instant. It is one
|
|
||||||
// judgement in Hololive Dreams; retain the header as the authoritative combo count.
|
|
||||||
for (let index = notes.length - 1; notes.filter((note) => note.type === 'regular').length > expectedRegular && index >= 0; index -= 1) {
|
|
||||||
if (notes[index].type === 'regular') notes.splice(index, 1);
|
|
||||||
}
|
|
||||||
const expectedMid = Number(headers.get('LONG_RELAY_NOTE_COUNT')) + Number(headers.get('LONG_CONTINUE_NOTE_COUNT'));
|
|
||||||
// Some editor streams encode a sub-half-beat terminal continuation. Preserve its score category
|
|
||||||
// even when the compact timeline cannot distinguish it from the neighbouring hold endpoint.
|
|
||||||
while (notes.filter((note) => note.type === 'mid').length < expectedMid) {
|
|
||||||
const anchor = notes.at(-1);
|
|
||||||
notes.push({ timeSeconds: anchor?.timeSeconds ?? 0, type: 'mid', isFlick: false });
|
|
||||||
}
|
|
||||||
notes.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
|
|
||||||
return { songId: headers.get('MUSIC_ID'), notes, specialStartSeconds: specialStarts.sort((left, right) => left - right).map(secondsAt) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const music = new Map(readMaster('kor', 'Music.json').map((row) => { const data = row.data ?? row; return [data.id, data]; }));
|
const music = new Map(readMaster('kor', 'Music.json').map((row) => { const data = row.data ?? row; return [data.id, data]; }));
|
||||||
const translation = new Map(readMaster('kor', 'LangMusic_Kor.json').map((row) => { const data = row.data ?? row; return [data.id, data.text]; }));
|
const translation = new Map(readMaster('kor', 'LangMusic_Kor.json').map((row) => { const data = row.data ?? row; return [data.id, data.text]; }));
|
||||||
const levels = new Map(readMaster('kor', 'MusicDifficulty.json').map((row) => { const data = row.data ?? row; return [`${data.musicId}.${data.difficultyType.split('_').at(-1).toLowerCase()}`, data.difficultyLevel]; }));
|
const levels = new Map(readMaster('kor', 'MusicDifficulty.json').map((row) => { const data = row.data ?? row; return [`${data.musicId}.${data.difficultyType.split('_').at(-1).toLowerCase()}`, data.difficultyLevel]; }));
|
||||||
const songIds = process.argv.slice(2);
|
const songIds = process.argv.slice(2);
|
||||||
if (songIds.length === 0) throw new Error('곡 ID를 지정하세요.');
|
if (songIds.length === 0) throw new Error('곡 ID 또는 곡 ID.난이도를 지정하세요.');
|
||||||
const singerOverrides = { m0527: 'group' };
|
const selected = songIds.flatMap((value) => {
|
||||||
|
const match = value.match(/^(m\d{4})(?:\.(easy|normal|hard|expert))?$/);
|
||||||
|
if (!match) throw new Error(`잘못된 채보 선택: ${value}`);
|
||||||
|
return (match[2] ? [match[2]] : difficulties).map((difficulty) => ({ songId: match[1], difficulty }));
|
||||||
|
});
|
||||||
const generatedCatalogEntries = [];
|
const generatedCatalogEntries = [];
|
||||||
for (const songId of songIds) for (const difficulty of difficulties) {
|
const outputs = [];
|
||||||
|
for (const { songId, difficulty } of selected) {
|
||||||
const file = path.resolve(`data/raw/sus/chart_${songId}_${difficulty}.sus`);
|
const file = path.resolve(`data/raw/sus/chart_${songId}_${difficulty}.sus`);
|
||||||
const bytes = await readFile(file);
|
const bytes = await readFile(file);
|
||||||
const timeline = parseSusTimeline(bytes.toString('utf8'));
|
const timeline = parseSusTimeline(bytes.toString('utf8'));
|
||||||
@@ -201,8 +34,8 @@ for (const songId of songIds) for (const difficulty of difficulties) {
|
|||||||
const difficultyLevel = levels.get(`${songId}.${difficulty}`);
|
const difficultyLevel = levels.get(`${songId}.${difficulty}`);
|
||||||
const susSha256 = createHash('sha256').update(bytes).digest('hex');
|
const susSha256 = createHash('sha256').update(bytes).digest('hex');
|
||||||
const formula = formulaScoreMetadata(songId, difficulty, difficultyLevel, timeline.notes.length, master.liveScoreCoefficientPermil, susSha256);
|
const formula = formulaScoreMetadata(songId, difficulty, difficultyLevel, timeline.notes.length, master.liveScoreCoefficientPermil, susSha256);
|
||||||
const output = { schemaVersion: 1, songId, difficulty, durationSeconds: master.playingSeconds, scoreRatio: formula?.scoreRatio ?? null, scoreCalibrationSource: formula?.scoreCalibrationSource ?? null, ...(formula ?? {}), notes: timeline.notes, specialStartSeconds: timeline.specialStartSeconds, singerType: singerOverrides[songId] ?? master.musicSingerType?.split('_').at(-1).toLowerCase() ?? null, singerCharacterIds: master.characterIds ?? [], source: { page: 'Hololive Dreams CDN via holodori-asset-tools', transcriptionStatus: 'exact_from_decrypted_sus', susSha256 } };
|
const output = { schemaVersion: 1, songId, difficulty, durationSeconds: master.playingSeconds, scoreRatio: formula?.scoreRatio ?? null, scoreCalibrationSource: formula?.scoreCalibrationSource ?? null, ...(formula ?? {}), notes: timeline.notes, specialStartSeconds: timeline.specialStartSeconds, singerType: master.musicSingerType?.split('_').at(-1).toLowerCase() ?? null, singerCharacterIds: master.characterIds ?? [], source: { page: 'Hololive Dreams CDN via holodori-asset-tools', transcriptionStatus: 'exact_from_decrypted_sus', susSha256 } };
|
||||||
await writeFile(path.resolve(`static/data/charts/${songId}.${difficulty}.json`), JSON.stringify(output));
|
outputs.push({ filename: path.resolve(`static/data/charts/${songId}.${difficulty}.json`), output });
|
||||||
generatedCatalogEntries.push({ songId, title: translation.get(master.titleLangId) ?? songId, difficulty, difficultyLevel, durationSeconds: master.playingSeconds, scoreRatio: output.scoreRatio, scoreCalibrationSource: output.scoreCalibrationSource, scoreFormula: output.scoreFormula, totalNotes: output.notes.length, singerType: output.singerType, singerCharacterIds: output.singerCharacterIds, releaseTime: Number(master.startTime), path: `/data/charts/${songId}.${difficulty}.json` });
|
generatedCatalogEntries.push({ songId, title: translation.get(master.titleLangId) ?? songId, difficulty, difficultyLevel, durationSeconds: master.playingSeconds, scoreRatio: output.scoreRatio, scoreCalibrationSource: output.scoreCalibrationSource, scoreFormula: output.scoreFormula, totalNotes: output.notes.length, singerType: output.singerType, singerCharacterIds: output.singerCharacterIds, releaseTime: Number(master.startTime), path: `/data/charts/${songId}.${difficulty}.json` });
|
||||||
console.log(`${songId}.${difficulty}: ${timeline.notes.length} notes ${timeline.specialStartSeconds.join(', ')}`);
|
console.log(`${songId}.${difficulty}: ${timeline.notes.length} notes ${timeline.specialStartSeconds.join(', ')}`);
|
||||||
}
|
}
|
||||||
@@ -211,4 +44,6 @@ const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
|
|||||||
const generatedKeys = new Set(generatedCatalogEntries.map((entry) => `${entry.songId}.${entry.difficulty}`));
|
const generatedKeys = new Set(generatedCatalogEntries.map((entry) => `${entry.songId}.${entry.difficulty}`));
|
||||||
catalog.charts = [...catalog.charts.filter((entry) => !generatedKeys.has(`${entry.songId}.${entry.difficulty}`)), ...generatedCatalogEntries]
|
catalog.charts = [...catalog.charts.filter((entry) => !generatedKeys.has(`${entry.songId}.${entry.difficulty}`)), ...generatedCatalogEntries]
|
||||||
.sort((left, right) => left.songId.localeCompare(right.songId) || left.difficulty.localeCompare(right.difficulty));
|
.sort((left, right) => left.songId.localeCompare(right.songId) || left.difficulty.localeCompare(right.difficulty));
|
||||||
|
// Finish validation of every selected chart before writing any service file.
|
||||||
|
for (const { filename, output } of outputs) await writeFile(filename, JSON.stringify(output));
|
||||||
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
||||||
|
|||||||
@@ -16800,7 +16800,7 @@
|
|||||||
"difficultyLevel": 3
|
"difficultyLevel": 3
|
||||||
},
|
},
|
||||||
"totalNotes": 102,
|
"totalNotes": 102,
|
||||||
"singerType": "group",
|
"singerType": "solo",
|
||||||
"singerCharacterIds": [
|
"singerCharacterIds": [
|
||||||
"chr-04016",
|
"chr-04016",
|
||||||
"chr-04017"
|
"chr-04017"
|
||||||
@@ -16822,7 +16822,7 @@
|
|||||||
"difficultyLevel": 24
|
"difficultyLevel": 24
|
||||||
},
|
},
|
||||||
"totalNotes": 682,
|
"totalNotes": 682,
|
||||||
"singerType": "group",
|
"singerType": "solo",
|
||||||
"singerCharacterIds": [
|
"singerCharacterIds": [
|
||||||
"chr-04016",
|
"chr-04016",
|
||||||
"chr-04017"
|
"chr-04017"
|
||||||
@@ -16844,7 +16844,7 @@
|
|||||||
"difficultyLevel": 17
|
"difficultyLevel": 17
|
||||||
},
|
},
|
||||||
"totalNotes": 420,
|
"totalNotes": 420,
|
||||||
"singerType": "group",
|
"singerType": "solo",
|
||||||
"singerCharacterIds": [
|
"singerCharacterIds": [
|
||||||
"chr-04016",
|
"chr-04016",
|
||||||
"chr-04017"
|
"chr-04017"
|
||||||
@@ -16866,7 +16866,7 @@
|
|||||||
"difficultyLevel": 11
|
"difficultyLevel": 11
|
||||||
},
|
},
|
||||||
"totalNotes": 285,
|
"totalNotes": 285,
|
||||||
"singerType": "group",
|
"singerType": "solo",
|
||||||
"singerCharacterIds": [
|
"singerCharacterIds": [
|
||||||
"chr-04016",
|
"chr-04016",
|
||||||
"chr-04017"
|
"chr-04017"
|
||||||
|
|||||||
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
Reference in New Issue
Block a user