Files
HolodoriCalc/scripts/audit-wf-charts.mjs
T
dal4segno eb294ff5e3
Publish GitHub Pages output / publish (push) Successful in 47s
fix: repair SUS timing and note classification for 18 charts
2026-09-09 14:31:25 +09:00

89 lines
6.1 KiB
JavaScript

// 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));