feat: chart note stats (credits to @Dale2003)

This commit is contained in:
Menci
2025-11-18 20:35:29 +08:00
parent a956db4650
commit ba1b876cb5
2 changed files with 75 additions and 15 deletions
+10 -5
View File
@@ -1,12 +1,17 @@
import type { MaimaiMajorVersionId, MaimaiRegion } from './base'; import type { MaimaiMajorVersionId, MaimaiRegion } from './base';
export type MaimaiChartNoteStats = {
tap: number;
hold: number;
slide: number;
touch: number;
break: number;
};
export type MaimaiChartMetadata = { export type MaimaiChartMetadata = {
// `level` is not included here and should be inferred from the change log. // `level` is not included here and should be inferred from the change log.
designer: string; designer: string;
// TODO: how many of TAP/HOLD/SLIDE/BREAK notes? stats?: MaimaiChartNoteStats;
};
export type MaimaiChartMetadataIntermediate = MaimaiChartMetadata & {
level: number;
}; };
export type MaimaiMusicMetadataBase = { export type MaimaiMusicMetadataBase = {
@@ -18,7 +23,7 @@ export type MaimaiMusicMetadataBase = {
}; };
export type MaimaiMusicMetadataIntermediate = MaimaiMusicMetadataBase & { export type MaimaiMusicMetadataIntermediate = MaimaiMusicMetadataBase & {
chartsWithLevel: MaimaiChartMetadataIntermediate[]; chartLevel: number[];
versionId: MaimaiMajorVersionId; versionId: MaimaiMajorVersionId;
deletedInPatch: boolean; deletedInPatch: boolean;
netOpenDate: string | null; netOpenDate: string | null;
+65 -10
View File
@@ -1,4 +1,9 @@
import type { MaimaiChartMetadataIntermediate, MaimaiMusicMetadata, MaimaiMusicMetadataIntermediate } from '../../interfaces'; import fs from 'node:fs';
import path from 'node:path';
import { glob } from 'glob';
import type { MaimaiChartMetadata, MaimaiChartNoteStats, MaimaiMusicMetadata, MaimaiMusicMetadataIntermediate } from '../../interfaces';
import { MaimaiRegion, MaimaiMajorVersionId, maimaiMajorVersionIds, MaimaiMusicAddDeleteLogEntry } from '../../interfaces'; import { MaimaiRegion, MaimaiMajorVersionId, maimaiMajorVersionIds, MaimaiMusicAddDeleteLogEntry } from '../../interfaces';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
import { forEachParallel, objectEntries, objectKeys } from '../../utils/base'; import { forEachParallel, objectEntries, objectKeys } from '../../utils/base';
@@ -13,9 +18,45 @@ const logger = createLogger('Music');
type IntermediateData = Record<number, MaimaiMusicMetadataIntermediate>; type IntermediateData = Record<number, MaimaiMusicMetadataIntermediate>;
const parseChartContent = (chartContent: string): MaimaiChartNoteStats => {
const result: MaimaiChartNoteStats = {
tap: 0,
hold: 0,
slide: 0,
touch: 0,
break: 0,
};
for (const line of chartContent.split('\n').map(line => line.trim())) {
if (line.startsWith('T_NUM_TAP')) {
result.tap = parseInt(line.split('\t')[1]?.trim() ?? '0');
} else if (line.startsWith('T_NUM_HLD')) {
result.hold = parseInt(line.split('\t')[1]?.trim() ?? '0');
} else if (line.startsWith('T_NUM_SLD')) {
result.slide = parseInt(line.split('\t')[1]?.trim() ?? '0');
} else if (line.startsWith('T_REC_TTP')) {
result.touch = parseInt(line.split('\t')[1]?.trim() ?? '0');
} else if (line.startsWith('T_NUM_BRK')) {
result.break = parseInt(line.split('\t')[1]?.trim() ?? '0');
}
}
if (!Object.values(result).every(Number.isSafeInteger)) throw new Error(`Chart parsed to invalid note stats: ${JSON.stringify(result)}`);
result.tap = result.tap - result.touch;
return result;
};
export const processMusic: WorkerProcessor<IntermediateData> = async ctx => { export const processMusic: WorkerProcessor<IntermediateData> = async ctx => {
const musics: Record<number, MaimaiMusicMetadataIntermediate> = {}; const musics: Record<number, MaimaiMusicMetadataIntermediate> = {};
await ctx.forEachAxxxDirOrdered(async axxxDir => await forEachParallel(parseXmls(globFiles(axxxDir, 'music', 'music', 'Music.xml')), async ({ fileName, xml: { MusicData } }) => { await ctx.forEachAxxxDirOrdered(async axxxDir => await forEachParallel(parseXmls(globFiles(axxxDir, 'music', 'music', 'Music.xml')), async ({ fileName, xml: { MusicData } }) => {
const musicDir = path.dirname(fileName);
const chartPaths = await glob(path.join(musicDir, '*.ma2'));
const chartStats = new Map(await Promise.all(chartPaths.map(async chartPath => {
const chartContent = await fs.promises.readFile(chartPath, 'utf-8');
const difficulty = path.basename(chartPath).split('_').pop()?.split('.')[0];
const numericDifficulty = difficulty === 'L' ? 0 : difficulty === 'R' ? 1 : Number(difficulty);
if (!Number.isSafeInteger(numericDifficulty)) throw new Error(`Failed to extract difficulty from chart path: ${chartPath}`);
return [numericDifficulty, parseChartContent(chartContent)] as const;
})));
const id = zCoerceNumber(MusicData.name.id); const id = zCoerceNumber(MusicData.name.id);
const name = zCoerceString(MusicData.name.str); const name = zCoerceString(MusicData.name.str);
const artist = zCoerceString(MusicData.artistName.str); const artist = zCoerceString(MusicData.artistName.str);
@@ -25,17 +66,19 @@ export const processMusic: WorkerProcessor<IntermediateData> = async ctx => {
const netOpenDate = parseNetOpenDate(MusicData.netOpenName.str); const netOpenDate = parseNetOpenDate(MusicData.netOpenName.str);
const subEventDate = parseEventIdAsNetOpenDate(MusicData.subEventName.id); const subEventDate = parseEventIdAsNetOpenDate(MusicData.subEventName.id);
const charts = (MusicData.notesData.Notes as any[]).map(note => const buildChartMetadata = (note: any, stats?: MaimaiChartNoteStats) => ({
level: zCoerceNumber(note.level) + zCoerceNumber(note.levelDecimal) / 10,
designer: zCoerceString(note.notesDesigner.str),
stats,
} satisfies MaimaiChartMetadata & { level: number });
const charts = (MusicData.notesData.Notes as any[]).map((note, difficulty) =>
// DX and DX+ version didn't set the `isEnable` flag. // DX and DX+ version didn't set the `isEnable` flag.
( (
ctx.version === MaimaiMajorVersionId.DX || ctx.version === MaimaiMajorVersionId.DX_PLUS ctx.version === MaimaiMajorVersionId.DX || ctx.version === MaimaiMajorVersionId.DX_PLUS
? !!note.level ? !!note.level
: note.isEnable : note.isEnable
) )
? { ? buildChartMetadata(note, chartStats.get(difficulty))
level: zCoerceNumber(note.level) + zCoerceNumber(note.levelDecimal) / 10,
designer: zCoerceString(note.notesDesigner.str),
} satisfies MaimaiChartMetadataIntermediate
: undefined); : undefined);
// Re:MASTER is removed (bugfix?). // Re:MASTER is removed (bugfix?).
@@ -47,6 +90,11 @@ export const processMusic: WorkerProcessor<IntermediateData> = async ctx => {
// Remove charts of nonexistent difficulties. // Remove charts of nonexistent difficulties.
while (charts.length > 0 && charts[charts.length - 1] === undefined) charts.pop(); while (charts.length > 0 && charts[charts.length - 1] === undefined) charts.pop();
// Utage charts may have 2 chart files but only one notesData (_L and _R).
if (id >= 100000 && charts.length === 1 && chartStats.size === 2) {
charts.push(buildChartMetadata(MusicData.notesData.Notes[0], chartStats.get(/* _R */ 1)));
}
// Normally musics are deleted only on major version updates. // Normally musics are deleted only on major version updates.
// However, sometimes they delete musics in patches due to political or copyright reasons. // However, sometimes they delete musics in patches due to political or copyright reasons.
const isDeleted = MusicData.eventName.id === 0; const isDeleted = MusicData.eventName.id === 0;
@@ -64,8 +112,8 @@ export const processMusic: WorkerProcessor<IntermediateData> = async ctx => {
genre, genre,
bpm, bpm,
versionId, versionId,
charts: charts.map(c => ({ designer: c!.designer })), charts: charts.map(c => ({ designer: c!.designer, stats: c!.stats })),
chartsWithLevel: charts.map(c => c!), chartLevel: charts.map(c => c!.level),
netOpenDate, netOpenDate,
subEventDate, subEventDate,
deletedInPatch: false, deletedInPatch: false,
@@ -121,9 +169,9 @@ export const mergeMusic: MetadataMerger<IntermediateData, Record<number, MaimaiM
for (const [idStr, music] of objectEntries(musics)) { for (const [idStr, music] of objectEntries(musics)) {
const id = Number(idStr); const id = Number(idStr);
const changeLog = (perRegionLevelChangeLog[id]![region] ??= []); const changeLog = (perRegionLevelChangeLog[id]![region] ??= []);
for (const [i, chart] of music.chartsWithLevel.entries()) { for (const [i, level] of music.chartLevel.entries()) {
unseenMusicIds.delete(id); unseenMusicIds.delete(id);
if (chart) (changeLog[i] ||= {})[version] = chart.level; (changeLog[i] ||= {})[version] = level;
} }
} }
// For each unseen music IDs, if we've seen it before in current region, mark it as "deleted from package". // For each unseen music IDs, if we've seen it before in current region, mark it as "deleted from package".
@@ -250,6 +298,13 @@ export const mergeMusic: MetadataMerger<IntermediateData, Record<number, MaimaiM
} }
} }
for (const [idStr, music] of objectEntries(result)) {
const id = Number(idStr);
for (const [difficulty, chart] of music.charts.entries()) {
if (!chart.stats) logger.warn(`Music ${id}[${difficulty}] (${music.name}) has no stats`);
}
}
// TODO: Merge to one change log and track unavailablity (done). CHN 1.20 levels mismatch? // TODO: Merge to one change log and track unavailablity (done). CHN 1.20 levels mismatch?
return result; return result;
}; };