feat: initial commit
This commit is contained in:
+143
@@ -0,0 +1,143 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
.output
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Sveltekit cache directory
|
||||||
|
.svelte-kit/
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# Firebase cache directory
|
||||||
|
.firebase/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v3
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# Vite files
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
/config.yaml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
inputs: {}
|
||||||
|
outputDir: output
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
inputs:
|
||||||
|
JPN:
|
||||||
|
22: D:\maimai\BUDDiES_PLUS
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||||
|
import importPlugin from 'eslint-plugin-import';
|
||||||
|
import stylisticPlugin from '@stylistic/eslint-plugin';
|
||||||
|
|
||||||
|
import type { Linter } from 'eslint';
|
||||||
|
|
||||||
|
const commonConfig: Linter.Config = {
|
||||||
|
plugins: {
|
||||||
|
import: importPlugin,
|
||||||
|
'@typescript-eslint': tsPlugin as any,
|
||||||
|
stylistic: stylisticPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'import/order': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
groups: ['builtin', 'external', ['internal', 'parent', 'sibling', 'index']],
|
||||||
|
pathGroups: [
|
||||||
|
{
|
||||||
|
pattern: '@proj-marina/**',
|
||||||
|
group: 'internal',
|
||||||
|
position: 'before',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: '@/**',
|
||||||
|
group: 'internal',
|
||||||
|
position: 'before',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'newlines-between': 'always',
|
||||||
|
distinctGroup: false,
|
||||||
|
alphabetize: {
|
||||||
|
order: 'asc',
|
||||||
|
caseInsensitive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'import/no-duplicates': 'error',
|
||||||
|
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'no-var': 'error',
|
||||||
|
'no-debugger': 'error',
|
||||||
|
'object-shorthand': 'error',
|
||||||
|
'prefer-template': 'error',
|
||||||
|
eqeqeq: ['error', 'always', { null: 'ignore' }],
|
||||||
|
|
||||||
|
'@typescript-eslint/prefer-optional-chain': 'error',
|
||||||
|
'@typescript-eslint/prefer-nullish-coalescing': 'error',
|
||||||
|
'@typescript-eslint/return-await': ['error', 'always'],
|
||||||
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
|
'@typescript-eslint/await-thenable': 'error',
|
||||||
|
'@typescript-eslint/no-misused-promises': ['error'],
|
||||||
|
'@typescript-eslint/prefer-as-const': 'error',
|
||||||
|
'@typescript-eslint/prefer-for-of': 'error',
|
||||||
|
'@typescript-eslint/prefer-includes': 'error',
|
||||||
|
'@typescript-eslint/prefer-string-starts-ends-with': 'error',
|
||||||
|
'@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }],
|
||||||
|
|
||||||
|
'stylistic/indent': ['error', 2, {
|
||||||
|
'offsetTernaryExpressions': true
|
||||||
|
}],
|
||||||
|
'stylistic/linebreak-style': ['error', 'unix'],
|
||||||
|
'stylistic/semi': ['error', 'always'],
|
||||||
|
'stylistic/quotes': ['error', 'single', {
|
||||||
|
'avoidEscape': true,
|
||||||
|
'allowTemplateLiterals': 'avoidEscape',
|
||||||
|
}],
|
||||||
|
'stylistic/comma-dangle': ['error', 'always-multiline'],
|
||||||
|
'stylistic/arrow-parens': ['error', 'as-needed'],
|
||||||
|
'stylistic/object-curly-spacing': ['error', 'always'],
|
||||||
|
'stylistic/array-bracket-spacing': ['error', 'never'],
|
||||||
|
'stylistic/space-before-function-paren': ['error', {
|
||||||
|
'anonymous': 'always',
|
||||||
|
'named': 'never',
|
||||||
|
'asyncArrow': 'always',
|
||||||
|
}],
|
||||||
|
'stylistic/space-in-parens': ['error', 'never'],
|
||||||
|
'stylistic/comma-spacing': ['error', { 'before': false, 'after': true }],
|
||||||
|
'stylistic/key-spacing': ['error', { 'beforeColon': false, 'afterColon': true }],
|
||||||
|
'stylistic/keyword-spacing': ['error'],
|
||||||
|
'stylistic/space-before-blocks': ['error', 'always'],
|
||||||
|
'stylistic/space-infix-ops': ['error'],
|
||||||
|
'stylistic/no-trailing-spaces': ['error'],
|
||||||
|
'stylistic/eol-last': ['error', 'always'],
|
||||||
|
'stylistic/no-multiple-empty-lines': ['error', { 'max': 1, 'maxEOF': 0 }],
|
||||||
|
'stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
|
||||||
|
'stylistic/object-curly-newline': ['error', {
|
||||||
|
'ObjectExpression': { 'multiline': true, 'consistent': true },
|
||||||
|
'ObjectPattern': { 'multiline': true, 'consistent': true },
|
||||||
|
'ImportDeclaration': { 'multiline': true, 'consistent': true },
|
||||||
|
'ExportDeclaration': { 'multiline': true, 'consistent': true }
|
||||||
|
}],
|
||||||
|
'stylistic/array-bracket-newline': ['error', 'consistent'],
|
||||||
|
'stylistic/function-paren-newline': ['error', 'consistent'],
|
||||||
|
'stylistic/member-delimiter-style': ['error', {
|
||||||
|
'multiline': {
|
||||||
|
'delimiter': 'semi',
|
||||||
|
'requireLast': true
|
||||||
|
},
|
||||||
|
'singleline': {
|
||||||
|
'delimiter': 'semi',
|
||||||
|
'requireLast': false
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
'stylistic/type-annotation-spacing': ['error'],
|
||||||
|
'stylistic/jsx-quotes': ['error', 'prefer-double'],
|
||||||
|
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'import/internal-regex': '^@proj-marina/',
|
||||||
|
'import/resolver': {
|
||||||
|
typescript: {
|
||||||
|
project: ['./tsconfig.json'],
|
||||||
|
noWarnOnMultipleProjects: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const parserOptions: Linter.ParserOptions = {
|
||||||
|
parser: tsParser,
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
project: ['./tsconfig.json'],
|
||||||
|
noWarnOnMultipleProjects: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: Linter.Config[] = [
|
||||||
|
{
|
||||||
|
...commonConfig,
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
parserOptions,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'**/node_modules/**',
|
||||||
|
// Build output
|
||||||
|
'**/dist/**',
|
||||||
|
'**/build/**',
|
||||||
|
'**/coverage/**',
|
||||||
|
'eslint.config.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "@maigolabs/kairos",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun src/index.ts",
|
||||||
|
"typecheck": "tsc",
|
||||||
|
"lint": "eslint --cache --ext .",
|
||||||
|
"lint:fix": "eslint --cache --ext . --fix"
|
||||||
|
},
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"packageManager": "pnpm@10.20.0",
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@stylistic/eslint-plugin": "^5.5.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||||
|
"@typescript-eslint/parser": "^8.46.3",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
|
"eslint-plugin-import": "^2.32.0",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@guiiai/logg": "^1.2.5",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/node": "^24.10.0",
|
||||||
|
"bun": "^1.3.2",
|
||||||
|
"es-toolkit": "^1.41.0",
|
||||||
|
"fast-xml-parser": "^5.3.1",
|
||||||
|
"glob": "^11.0.3",
|
||||||
|
"tinypool": "^2.0.0",
|
||||||
|
"zod": "^4.1.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2960
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- bun
|
||||||
|
- unrs-resolver
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { merge } from 'es-toolkit';
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { MaimaiRegion, MaimaiMajorVersionId } from './interfaces/index';
|
||||||
|
import { run } from './master';
|
||||||
|
|
||||||
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export const Config = z.object({
|
||||||
|
inputs: z.record(
|
||||||
|
z.enum(MaimaiRegion),
|
||||||
|
z.record(z.preprocess(Number, z.enum(MaimaiMajorVersionId)), z.string()),
|
||||||
|
),
|
||||||
|
outputDir: z.string(),
|
||||||
|
});
|
||||||
|
export type Config = z.infer<typeof Config>;
|
||||||
|
|
||||||
|
const loadConfigFile = (filename: string) => yaml.load(fs.readFileSync(path.resolve(dirname, '..', filename), 'utf-8')) as Record<string, unknown>;
|
||||||
|
const config = Config.parse(merge(loadConfigFile('config.base.yaml'), loadConfigFile('config.yaml')));
|
||||||
|
|
||||||
|
await run(config);
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export enum MaimaiRegion {
|
||||||
|
JPN = 'JPN',
|
||||||
|
EXP = 'EXP',
|
||||||
|
CHN = 'CHN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maimaiRegionFromGameId(gameId: string) {
|
||||||
|
switch (gameId) {
|
||||||
|
case '\x53\x44\x45\x5a':
|
||||||
|
return MaimaiRegion.JPN;
|
||||||
|
case '\x53\x44\x47\x41':
|
||||||
|
return MaimaiRegion.EXP;
|
||||||
|
case '\x53\x44\x47\x42':
|
||||||
|
return MaimaiRegion.CHN;
|
||||||
|
default: // Fallback to JPN
|
||||||
|
return MaimaiRegion.JPN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MaimaiMajorVersionId {
|
||||||
|
maimai = 0,
|
||||||
|
maimai_PLUS = 1,
|
||||||
|
GreeN = 2,
|
||||||
|
GreeN_PLUS = 3,
|
||||||
|
ORANGE = 4,
|
||||||
|
ORANGE_PLUS = 5,
|
||||||
|
PiNK = 6,
|
||||||
|
PiNK_PLUS = 7,
|
||||||
|
MURASAKi = 8,
|
||||||
|
MURASAKi_PLUS = 9,
|
||||||
|
MiLK = 10,
|
||||||
|
MiLK_PLUS = 11,
|
||||||
|
FiNALE = 12,
|
||||||
|
DX = 13,
|
||||||
|
DX_PLUS = 14,
|
||||||
|
Splash = 15,
|
||||||
|
Splash_PLUS = 16,
|
||||||
|
UNiVERSE = 17,
|
||||||
|
UNiVERSE_PLUS = 18,
|
||||||
|
FESTiVAL = 19,
|
||||||
|
FESTiVAL_PLUS = 20,
|
||||||
|
BUDDiES = 21,
|
||||||
|
BUDDiES_PLUS = 22,
|
||||||
|
PRiSM = 23,
|
||||||
|
PRiSM_PLUS = 24,
|
||||||
|
CiRCLE = 25,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const maimaiMajorVersionIds = Object.values(MaimaiMajorVersionId).filter(
|
||||||
|
v => typeof v === 'number',
|
||||||
|
) as MaimaiMajorVersionId[];
|
||||||
|
|
||||||
|
export function maimaiVersionIdFromVersionString(gameVersion: string) {
|
||||||
|
if (!gameVersion) {
|
||||||
|
return maimaiMajorVersionIds[maimaiMajorVersionIds.length - 1];
|
||||||
|
}
|
||||||
|
const v = Number(gameVersion.split('.')[1]);
|
||||||
|
const i = Math.floor(v / 5);
|
||||||
|
const result = MaimaiMajorVersionId.DX + i;
|
||||||
|
return maimaiMajorVersionIds.includes(result)
|
||||||
|
? result
|
||||||
|
: /* Fallback to the latest version */ maimaiMajorVersionIds[maimaiMajorVersionIds.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MetadataUnversioned<T> = { unversioned: T };
|
||||||
|
export type MetadataVersioned<T> = { versioned: Partial<Record<MaimaiMajorVersionId, T>> };
|
||||||
|
export type MetadataMaybeVersioned<T> = MetadataUnversioned<T> | MetadataVersioned<T>;
|
||||||
|
|
||||||
|
export type MetadataUnregionalized<T> = { unregionalized: MetadataMaybeVersioned<T> };
|
||||||
|
export type MetadataRegionalized<T> = { regionalized: Partial<Record<MaimaiRegion, MetadataMaybeVersioned<T>>> };
|
||||||
|
export type MetadataMaybeRegionalized<T> = MetadataUnregionalized<T> | MetadataRegionalized<T>;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './base';
|
||||||
|
export * from './music';
|
||||||
|
export * from './title';
|
||||||
|
|
||||||
|
export const maimaiMetadataKinds = ['music', 'title', 'frame', 'icon', 'partner', 'plate', 'chara', 'card', 'loginBonus'] as const;
|
||||||
|
export type MaimaiMetadataKind = (typeof maimaiMetadataKinds)[number];
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { MaimaiMajorVersionId, MaimaiRegion } from './base';
|
||||||
|
|
||||||
|
export type MaimaiChartMetadata = {
|
||||||
|
// `level` is not included here and should be inferred from the change log.
|
||||||
|
designer: string;
|
||||||
|
// TODO: how many of TAP/HOLD/SLIDE/BREAK notes?
|
||||||
|
};
|
||||||
|
export type MaimaiChartMetadataIntermediate = MaimaiChartMetadata & {
|
||||||
|
level: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MaimaiMusicMetadataBase = {
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
genre: string;
|
||||||
|
bpm: number;
|
||||||
|
charts: MaimaiChartMetadata[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MaimaiMusicMetadataIntermediate = MaimaiMusicMetadataBase & {
|
||||||
|
chartsWithLevel: MaimaiChartMetadataIntermediate[];
|
||||||
|
versionId: MaimaiMajorVersionId;
|
||||||
|
deletedInPatch: boolean;
|
||||||
|
netOpenDate: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum MaimaiMusicAddDeleteLogEntry {
|
||||||
|
Added = 1,
|
||||||
|
AddedReMaster = 2,
|
||||||
|
DeletedFromPackage = 3,
|
||||||
|
DeletedInPatch = 4,
|
||||||
|
}
|
||||||
|
export type MaimaiMusicAddDeleteLog = Partial<Record<MaimaiMajorVersionId, MaimaiMusicAddDeleteLogEntry>>;
|
||||||
|
export interface MaimaiMusicMetadataRegionalInfo {
|
||||||
|
/**
|
||||||
|
* For a music deleted from package, it don't have the level value in that version.
|
||||||
|
* For a music deleted in patch, it has the level value in that version.
|
||||||
|
*/
|
||||||
|
addDeleteLog: MaimaiMusicAddDeleteLog;
|
||||||
|
netOpenDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MaimaiMusicLevelChangeLog = Partial<Record<MaimaiMajorVersionId, number>>[];
|
||||||
|
export interface MaimaiMusicMetadata extends MaimaiMusicMetadataBase {
|
||||||
|
/**
|
||||||
|
* The level change log per chart (BASIC, ADVANCED, EXPERT, MASTER, Re:MASTER).
|
||||||
|
*/
|
||||||
|
levelChangeLog: MaimaiMusicLevelChangeLog; // This is observed to be identical for all regions.
|
||||||
|
regionalInfo: Partial<Record<MaimaiRegion, MaimaiMusicMetadataRegionalInfo>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { MaimaiRegion, MetadataMaybeRegionalized } from './base';
|
||||||
|
|
||||||
|
export enum MaimaiTitleRareType {
|
||||||
|
Normal = 'Normal',
|
||||||
|
Bronze = 'Bronze',
|
||||||
|
Silver = 'Silver',
|
||||||
|
Gold = 'Gold',
|
||||||
|
Rainbow = 'Rainbow',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BasicMetadataBase<TExtra> = TExtra & { name: string };
|
||||||
|
export type BasicMetadataIntermediate<TExtra> = BasicMetadataBase<TExtra> & { netOpenDate: string | null };
|
||||||
|
export type BasicMetadata<TExtra = {}> = MetadataMaybeRegionalized<BasicMetadataBase<TExtra>> & {
|
||||||
|
regionalNetOpenDate: Partial<Record<MaimaiRegion, string | null>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MaimaiTitleMetadataExtra = { rareType: MaimaiTitleRareType };
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { initLogger, useGlobalLogger } from '@guiiai/logg';
|
||||||
|
|
||||||
|
initLogger();
|
||||||
|
|
||||||
|
export const createLogger = (name: string) => useGlobalLogger(name);
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import Tinypool from 'tinypool';
|
||||||
|
|
||||||
|
import type { Config } from './index';
|
||||||
|
import type { MaimaiMajorVersionId, MaimaiMetadataKind, MaimaiRegion } from './interfaces';
|
||||||
|
import { maimaiMetadataKinds } from './interfaces';
|
||||||
|
import { createLogger } from './logger';
|
||||||
|
import { basicDataTypes } from './processors/basic';
|
||||||
|
import { mergeMusic } from './processors/music';
|
||||||
|
import { arrayToObject, getOrSet, objectEntries, objectMap } from './utils/base';
|
||||||
|
import type { WorkerArguments } from './worker';
|
||||||
|
|
||||||
|
const logger = createLogger('Master');
|
||||||
|
|
||||||
|
const pool = new Tinypool({
|
||||||
|
filename: new URL('./worker.ts', import.meta.url).href,
|
||||||
|
minThreads: 8,
|
||||||
|
maxThreads: 32,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IntermediateDataMap<TIntermediateData> = Map<MaimaiRegion, Map<MaimaiMajorVersionId, TIntermediateData>>;
|
||||||
|
export type MetadataMerger<TIntermediateData, TResult> = (intermediateDataMap: IntermediateDataMap<TIntermediateData>) => TResult;
|
||||||
|
|
||||||
|
export const run = async (config: Config) => {
|
||||||
|
const outputDir = path.resolve(config.outputDir);
|
||||||
|
await fs.promises.mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const index: Map<MaimaiMetadataKind, Map<MaimaiRegion, Map<MaimaiMajorVersionId, string>>> = new Map();
|
||||||
|
const tasks: Promise<void>[] = [];
|
||||||
|
for (const [regionName, versionPathMap] of objectEntries(config.inputs)) {
|
||||||
|
const region = regionName as MaimaiRegion;
|
||||||
|
for (const [versionName, streamingAssetsPath] of objectEntries(versionPathMap)) {
|
||||||
|
const version = Number(versionName) as MaimaiMajorVersionId;
|
||||||
|
const versionOutputDir = path.resolve(outputDir, 'intermediate', `${region}-${version}`);
|
||||||
|
await fs.promises.mkdir(versionOutputDir, { recursive: true });
|
||||||
|
|
||||||
|
const filePaths = arrayToObject(maimaiMetadataKinds, kind => path.resolve(versionOutputDir, `${kind}.json`));
|
||||||
|
|
||||||
|
for (const [metadataKind, outputFilePath] of objectEntries(filePaths)) {
|
||||||
|
const regionIndex = getOrSet(index, metadataKind as MaimaiMetadataKind, new Map());
|
||||||
|
const versionIndex = getOrSet(regionIndex, region, new Map());
|
||||||
|
versionIndex.set(version, outputFilePath);
|
||||||
|
|
||||||
|
if (fs.existsSync(outputFilePath)) continue;
|
||||||
|
|
||||||
|
tasks.push(pool.run({
|
||||||
|
region,
|
||||||
|
version,
|
||||||
|
streamingAssetsPath,
|
||||||
|
metadataKind,
|
||||||
|
outputFilePath,
|
||||||
|
} satisfies WorkerArguments));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(tasks);
|
||||||
|
|
||||||
|
const dataMap: Map<MaimaiMetadataKind, IntermediateDataMap<unknown>> = new Map();
|
||||||
|
for (const [kind, regionMergeIndex] of index.entries()) {
|
||||||
|
const regionMap = getOrSet(dataMap, kind, new Map());
|
||||||
|
for (const [region, versionMergeIndex] of regionMergeIndex.entries()) {
|
||||||
|
const versionMap = getOrSet(regionMap, region, new Map());
|
||||||
|
for (const [version, outputFilePath] of versionMergeIndex.entries()) {
|
||||||
|
const data = JSON.parse(await fs.promises.readFile(outputFilePath, 'utf-8'));
|
||||||
|
versionMap.set(version, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergers: Record<MaimaiMetadataKind, MetadataMerger<any, any>> = {
|
||||||
|
...objectMap(basicDataTypes, dataType => dataType.merge),
|
||||||
|
music: mergeMusic,
|
||||||
|
};
|
||||||
|
const result = objectEntries(mergers).reduce((result, [kind, merger]) => {
|
||||||
|
try {
|
||||||
|
result[kind] = merger(dataMap.get(kind)!);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to merge ${kind} metadata`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, {} as Record<MaimaiMetadataKind, unknown>);
|
||||||
|
|
||||||
|
const mergedFilePath = path.resolve(outputDir, 'merged.json');
|
||||||
|
await fs.promises.writeFile(mergedFilePath, JSON.stringify(result, null, 2));
|
||||||
|
logger.log(`Merged metadata to ${mergedFilePath}`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { omit } from 'es-toolkit';
|
||||||
|
|
||||||
|
import type { BasicMetadata, BasicMetadataBase, BasicMetadataIntermediate, MaimaiTitleMetadataExtra } from '../interfaces';
|
||||||
|
import { MaimaiTitleRareType } from '../interfaces';
|
||||||
|
import type { MetadataMerger } from '../master';
|
||||||
|
import { forEachParallel, objectMap } from '../utils/base';
|
||||||
|
import type { RegionalizedMap, RegionalizedNetOpenDate } from '../utils/data';
|
||||||
|
import { maybeCompactRegionalizedMap, parseNetOpenDate } from '../utils/data';
|
||||||
|
import { forEachRegionAndVersion } from '../utils/each';
|
||||||
|
import { globFiles, parseXmls } from '../utils/fs';
|
||||||
|
import { zCoerceNumber, zCoerceString, zParseEnum } from '../utils/zod';
|
||||||
|
import type { WorkerProcessor } from '../worker';
|
||||||
|
|
||||||
|
type IntermediateData<TExtra> = Record<number, BasicMetadataIntermediate<TExtra>>;
|
||||||
|
|
||||||
|
const defineDataType = <TExtra = {}>(
|
||||||
|
globXmls: (axxxDir: string) => ReturnType<typeof globFiles>,
|
||||||
|
xmlRootElementName: string,
|
||||||
|
parseExtraFields: (xmlData: any) => TExtra = () => ({}) as any,
|
||||||
|
): {
|
||||||
|
process: WorkerProcessor<IntermediateData<TExtra>>;
|
||||||
|
merge: MetadataMerger<IntermediateData<TExtra>, Record<number, BasicMetadata<TExtra>>>;
|
||||||
|
} => ({
|
||||||
|
process: async ctx => {
|
||||||
|
const result: Record<number, BasicMetadataIntermediate<TExtra>> = {};
|
||||||
|
await ctx.forEachAxxxDirOrdered(async axxxDir => await forEachParallel(parseXmls(globXmls(axxxDir)), async ({ xml }) => {
|
||||||
|
const xmlData = xml[xmlRootElementName];
|
||||||
|
const id = zCoerceNumber(xmlData.name.id);
|
||||||
|
const name = zCoerceString(xmlData.name.str);
|
||||||
|
const netOpenDate = parseNetOpenDate(xmlData.netOpenName.str);
|
||||||
|
result[id] = { name, netOpenDate, ...parseExtraFields!(xmlData) };
|
||||||
|
}));
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
merge: dataMap => {
|
||||||
|
// Merge
|
||||||
|
const regionalNetOpenDate: Record<number, RegionalizedNetOpenDate> = {};
|
||||||
|
const mergedMap: Record<number, RegionalizedMap<BasicMetadataBase<TExtra>>> = {};
|
||||||
|
forEachRegionAndVersion(dataMap, 'jpnFirst', 'oldFirst', (region, version, entries) => Object.entries(entries).forEach(([idStr, entry]) => {
|
||||||
|
const id = Number(idStr);
|
||||||
|
const regionalizedMap = mergedMap[id] ??= {};
|
||||||
|
const versionedMap = regionalizedMap[region] ??= {};
|
||||||
|
versionedMap[version] = omit(entry, ['netOpenDate']) as BasicMetadataBase<TExtra>;
|
||||||
|
|
||||||
|
(regionalNetOpenDate[id] ??= {})[region] ??= null;
|
||||||
|
if (entry.netOpenDate) (regionalNetOpenDate[id] ??= {})[region] = entry.netOpenDate; // Newer version overrides the older one.
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Compact
|
||||||
|
return objectMap(mergedMap, (regionalizedMap, idStr) => ({
|
||||||
|
...maybeCompactRegionalizedMap(regionalizedMap!),
|
||||||
|
regionalNetOpenDate: regionalNetOpenDate[Number(idStr)]!,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const basicDataTypes = {
|
||||||
|
title: defineDataType<MaimaiTitleMetadataExtra>(axxxDir => globFiles(axxxDir, 'title', 'title', 'Title.xml'), 'TitleData', TitleData => ({
|
||||||
|
rareType: zParseEnum(MaimaiTitleRareType, TitleData.rareType),
|
||||||
|
})),
|
||||||
|
frame: defineDataType(axxxDir => globFiles(axxxDir, 'frame', 'frame', 'Frame.xml'), 'FrameData'),
|
||||||
|
icon: defineDataType(axxxDir => globFiles(axxxDir, 'icon', 'icon', 'Icon.xml'), 'IconData'),
|
||||||
|
partner: defineDataType(axxxDir => globFiles(axxxDir, 'partner', 'partner', 'Partner.xml'), 'PartnerData'),
|
||||||
|
plate: defineDataType(axxxDir => globFiles(axxxDir, 'plate', 'plate', 'Plate.xml'), 'PlateData'),
|
||||||
|
chara: defineDataType(axxxDir => globFiles(axxxDir, 'chara', 'chara', 'Chara.xml'), 'CharaData'),
|
||||||
|
card: defineDataType(axxxDir => globFiles(axxxDir, 'card', 'card', 'Card.xml'), 'CardData'),
|
||||||
|
loginBonus: defineDataType(axxxDir => globFiles(axxxDir, 'loginBonus', 'LoginBonus', 'LoginBonus.xml'), 'LoginBonusData'),
|
||||||
|
};
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import type { MaimaiChartMetadataIntermediate, MaimaiMusicMetadata, MaimaiMusicMetadataIntermediate } from '../interfaces';
|
||||||
|
import { MaimaiRegion, MaimaiMajorVersionId, maimaiMajorVersionIds, MaimaiMusicAddDeleteLogEntry } from '../interfaces';
|
||||||
|
import { createLogger } from '../logger';
|
||||||
|
import type { MetadataMerger } from '../master';
|
||||||
|
import { forEachParallel, objectEntries, objectKeys } from '../utils/base';
|
||||||
|
import { parseNetOpenDate } from '../utils/data';
|
||||||
|
import { forEachRegionAndVersion } from '../utils/each';
|
||||||
|
import { globFiles, parseXmls } from '../utils/fs';
|
||||||
|
import { zCoerceNumber, zCoerceString, zParseEnum } from '../utils/zod';
|
||||||
|
import type { WorkerProcessor } from '../worker';
|
||||||
|
|
||||||
|
const logger = createLogger('Music');
|
||||||
|
|
||||||
|
type IntermediateData = Record<number, MaimaiMusicMetadataIntermediate>;
|
||||||
|
|
||||||
|
export const processMusic: WorkerProcessor<IntermediateData> = async ctx => {
|
||||||
|
const musics: Record<number, MaimaiMusicMetadataIntermediate> = {};
|
||||||
|
await ctx.forEachAxxxDirOrdered(async axxxDir => await forEachParallel(parseXmls(globFiles(axxxDir, 'music', 'music', 'Music.xml')), async ({ fileName, xml: { MusicData } }) => {
|
||||||
|
const id = zCoerceNumber(MusicData.name.id);
|
||||||
|
const name = zCoerceString(MusicData.name.str);
|
||||||
|
const artist = zCoerceString(MusicData.artistName.str);
|
||||||
|
const genre = zCoerceString(MusicData.genreName.str);
|
||||||
|
const bpm = zCoerceNumber(MusicData.bpm);
|
||||||
|
const versionId = zParseEnum(MaimaiMajorVersionId, MusicData.AddVersion.id);
|
||||||
|
const netOpenDate = parseNetOpenDate(MusicData.netOpenName.str);
|
||||||
|
|
||||||
|
const charts = (MusicData.notesData.Notes as any[]).map(note =>
|
||||||
|
// DX and DX+ version didn't set the `isEnable` flag.
|
||||||
|
(
|
||||||
|
ctx.version === MaimaiMajorVersionId.DX || ctx.version === MaimaiMajorVersionId.DX_PLUS
|
||||||
|
? !!note.level
|
||||||
|
: note.isEnable
|
||||||
|
)
|
||||||
|
? {
|
||||||
|
level: zCoerceNumber(note.level) + zCoerceNumber(note.levelDecimal) / 10,
|
||||||
|
designer: zCoerceString(note.notesDesigner.str),
|
||||||
|
} satisfies MaimaiChartMetadataIntermediate
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
// Remove charts of nonexistent difficulties.
|
||||||
|
while (charts.length > 0 && charts[charts.length - 1] === undefined) charts.pop();
|
||||||
|
|
||||||
|
// Normally musics are deleted only on major version updates.
|
||||||
|
// However, sometimes they delete musics in patches due to political or copyright reasons.
|
||||||
|
const isDeleted = MusicData.eventName.id === 0;
|
||||||
|
if (isDeleted) {
|
||||||
|
logger.warn(`Music ${fileName} (${name}) deleted by patch!`);
|
||||||
|
const existingMusic = musics[id];
|
||||||
|
if (!existingMusic) logger.error(`Music ${fileName} (${name}) is detected to be deleted by patch but not found!`);
|
||||||
|
else existingMusic.deletedInPatch = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
musics[id] = {
|
||||||
|
name,
|
||||||
|
artist,
|
||||||
|
genre,
|
||||||
|
bpm,
|
||||||
|
versionId,
|
||||||
|
charts: charts.map(c => ({ designer: c!.designer })),
|
||||||
|
chartsWithLevel: charts.map(c => c!),
|
||||||
|
netOpenDate,
|
||||||
|
deletedInPatch: false,
|
||||||
|
} satisfies MaimaiMusicMetadataIntermediate;
|
||||||
|
}));
|
||||||
|
return musics;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mergeMusic: MetadataMerger<IntermediateData, Record<number, MaimaiMusicMetadata>> = dataMap => {
|
||||||
|
const result: Record<number, MaimaiMusicMetadata> = {};
|
||||||
|
|
||||||
|
// Merge all musics. JPN first. Nerwer version first.
|
||||||
|
const lowsetSeenVersionId: Record<number, MaimaiMajorVersionId> = {};
|
||||||
|
forEachRegionAndVersion(dataMap, 'jpnFirst', 'newFirst', (region, version, musics) => Object.entries(musics).forEach(([idStr, music]) => {
|
||||||
|
const id = Number(idStr);
|
||||||
|
const resultMusic = result[id] ??= {
|
||||||
|
name: music.name,
|
||||||
|
artist: music.artist,
|
||||||
|
genre: music.genre,
|
||||||
|
bpm: music.bpm,
|
||||||
|
charts: music.charts,
|
||||||
|
levelChangeLog: [],
|
||||||
|
regionalInfo: {},
|
||||||
|
};
|
||||||
|
if (resultMusic.charts.length < music.charts.length) {
|
||||||
|
// Re:MASTER added.
|
||||||
|
resultMusic.charts.push(...music.charts.slice(resultMusic.charts.length));
|
||||||
|
}
|
||||||
|
const regionalInfo = resultMusic.regionalInfo[region] ??= {
|
||||||
|
addDeleteLog: {},
|
||||||
|
netOpenDate: null,
|
||||||
|
};
|
||||||
|
regionalInfo.netOpenDate = music.netOpenDate; // The newest version comes first.
|
||||||
|
lowsetSeenVersionId[id] = Math.min(lowsetSeenVersionId[id] ?? version, version);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const knownMusicIds = new Set(objectKeys(result).map(Number));
|
||||||
|
const perRegionLevelChangeLog: Record<
|
||||||
|
number,
|
||||||
|
Partial<Record<MaimaiRegion, Partial<Record<MaimaiMajorVersionId, number | null>>[]>>
|
||||||
|
> = Object.fromEntries(objectKeys(result).map(id => [id, {}]));
|
||||||
|
// Process the change log - with the release version order.
|
||||||
|
forEachRegionAndVersion(dataMap, 'jpnFirst', 'oldFirst', (region, version, musics) => {
|
||||||
|
// Track the music IDs known but not seen in the current version.
|
||||||
|
const unseenMusicIds = new Set(knownMusicIds);
|
||||||
|
// Set the level in change log for the version, for each music and chart.
|
||||||
|
for (const [idStr, music] of objectEntries(musics)) {
|
||||||
|
const id = Number(idStr);
|
||||||
|
const changeLog = (perRegionLevelChangeLog[id]![region] ??= []);
|
||||||
|
for (const [i, chart] of music.chartsWithLevel.entries()) {
|
||||||
|
unseenMusicIds.delete(id);
|
||||||
|
if (chart) (changeLog[i] ||= {})[version] = chart.level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For each unseen music IDs, if we've seen it before in current region, mark it as "deleted from package".
|
||||||
|
for (const musicId of unseenMusicIds) {
|
||||||
|
const changeLog = perRegionLevelChangeLog[musicId]![region];
|
||||||
|
if (changeLog) {
|
||||||
|
let deleted = false;
|
||||||
|
for (const chart of changeLog) {
|
||||||
|
if (chart) {
|
||||||
|
chart[version] = null; // Mark as "deleted from package".
|
||||||
|
deleted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted) logger.verbose(`Music ${musicId} (${result[musicId]!.name}) is deleted in ${MaimaiMajorVersionId[version]} package`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check -- one chart's level should keep the same in the same version across regions.
|
||||||
|
for (const musicId of knownMusicIds) {
|
||||||
|
for (let difficulty = 0; difficulty <= 4; difficulty++) {
|
||||||
|
for (const version of maimaiMajorVersionIds) {
|
||||||
|
const resultMusic = result[musicId]!;
|
||||||
|
let known: { region: string; level: number } | undefined;
|
||||||
|
for (const region of objectKeys(resultMusic.regionalInfo)) {
|
||||||
|
if (region === MaimaiRegion.CHN) continue;
|
||||||
|
const changeLog = perRegionLevelChangeLog[musicId]![region]![difficulty];
|
||||||
|
const level = changeLog?.[version];
|
||||||
|
if (level) {
|
||||||
|
if (known) {
|
||||||
|
if (known.level !== level) {
|
||||||
|
logger.error(
|
||||||
|
`Chart ${musicId}[${difficulty}] (${result[musicId]!.name}) has different same-version level (${known.region}: ${known.level}, ${region}: ${level}) in ${MaimaiMajorVersionId[version]}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
known = { region, level };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check -- if a music is added in a non-JPN region before the JPN version. the level should be the same.
|
||||||
|
if (region !== MaimaiRegion.JPN) {
|
||||||
|
const jpnChangeLog = perRegionLevelChangeLog[musicId]![MaimaiRegion.JPN]?.[difficulty];
|
||||||
|
if (jpnChangeLog) {
|
||||||
|
const firstVersion = lowsetSeenVersionId[musicId]!;
|
||||||
|
const firstLevel = perRegionLevelChangeLog[musicId]![MaimaiRegion.JPN]![difficulty]?.[firstVersion];
|
||||||
|
if (firstLevel && version < firstVersion) {
|
||||||
|
if (level !== firstLevel) {
|
||||||
|
logger.error(
|
||||||
|
`Chart ${musicId}[${difficulty}] (${result[musicId]!.name}) has different level (${region}: ${level}, JPN: ${firstLevel}) in ${MaimaiMajorVersionId[version]} (JPN added in ${MaimaiMajorVersionId[firstVersion]})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.verbose(
|
||||||
|
`Chart ${musicId}[${difficulty}] (${result[musicId]!.name}) has the same level (${level}) in ${MaimaiMajorVersionId[version]} (JPN added in ${MaimaiMajorVersionId[firstVersion]})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// else: missing data.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill the `levelChangeLog` and `addDeleteLog` in the result with `perRegionLevelChangeLog`.
|
||||||
|
for (const [idStr, music] of objectEntries(result)) {
|
||||||
|
const id = Number(idStr);
|
||||||
|
const levelChangeLog = music.levelChangeLog;
|
||||||
|
for (const region of objectKeys(music.regionalInfo)) {
|
||||||
|
const regionLevelChangeLog = perRegionLevelChangeLog[id]![region]!;
|
||||||
|
if (region !== MaimaiRegion.CHN) {
|
||||||
|
for (const [difficulty, regionLevelChangeLogEntries] of regionLevelChangeLog.entries()) {
|
||||||
|
levelChangeLog[difficulty] ||= {};
|
||||||
|
for (const [version, level] of objectEntries(regionLevelChangeLogEntries)) {
|
||||||
|
if (level != null) {
|
||||||
|
levelChangeLog[difficulty]![version] = level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { addDeleteLog } = music.regionalInfo[region]!;
|
||||||
|
let previousVersion: MaimaiMajorVersionId | undefined;
|
||||||
|
for (const version of dataMap.get(region)!.keys()) {
|
||||||
|
let addDeleteLogEntry: MaimaiMusicAddDeleteLogEntry | undefined;
|
||||||
|
if (
|
||||||
|
regionLevelChangeLog[0]![version] != null &&
|
||||||
|
(previousVersion === undefined || regionLevelChangeLog[0]![previousVersion] == null)
|
||||||
|
) {
|
||||||
|
addDeleteLogEntry = MaimaiMusicAddDeleteLogEntry.Added;
|
||||||
|
} else if (
|
||||||
|
previousVersion !== undefined &&
|
||||||
|
regionLevelChangeLog[4]?.[version] != null &&
|
||||||
|
regionLevelChangeLog[4]?.[previousVersion] == null
|
||||||
|
) {
|
||||||
|
addDeleteLogEntry = MaimaiMusicAddDeleteLogEntry.AddedReMaster;
|
||||||
|
} else if (previousVersion !== undefined && regionLevelChangeLog[0]![previousVersion] != null) {
|
||||||
|
const input = dataMap.get(region)!.get(version)!;
|
||||||
|
if (regionLevelChangeLog[0]![version] == null) {
|
||||||
|
addDeleteLogEntry = MaimaiMusicAddDeleteLogEntry.DeletedFromPackage;
|
||||||
|
} else if (input[id]!.deletedInPatch) {
|
||||||
|
addDeleteLogEntry = MaimaiMusicAddDeleteLogEntry.DeletedInPatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (addDeleteLogEntry) {
|
||||||
|
addDeleteLog[version] = addDeleteLogEntry;
|
||||||
|
}
|
||||||
|
previousVersion = version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the unchanged version entries from level change log.
|
||||||
|
for (const levelChangeLogEntries of levelChangeLog.values()) {
|
||||||
|
const entries = objectEntries(levelChangeLogEntries).sort(([a], [b]) => Number(a) - Number(b));
|
||||||
|
for (const [i, [version, level]] of entries.entries()) {
|
||||||
|
// The first entry is always kept.
|
||||||
|
if (i === 0) continue;
|
||||||
|
// Check if the level is changed from the previous version.
|
||||||
|
const previousLevel = entries[i - 1]![1];
|
||||||
|
if (level === previousLevel) {
|
||||||
|
delete levelChangeLogEntries[version];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Merge to one change log and track unavailablity (done). CHN 1.20 levels mismatch?
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export const objectEntries = <T extends object>(obj: T) => Object.entries(obj) as Array<[keyof T, T[keyof T]]>;
|
||||||
|
|
||||||
|
export const objectFromEntries = <T extends [string | number | symbol, unknown][]>(entries: T) => Object.fromEntries(entries) as Record<T[number][0], T[number][1]>;
|
||||||
|
|
||||||
|
export const objectKeys = <T extends object>(obj: T) => Object.keys(obj) as (keyof T)[];
|
||||||
|
|
||||||
|
export const objectMap = <T extends object, R>(obj: T, fn: (value: T[keyof T], key: keyof T) => R) =>
|
||||||
|
Object.fromEntries(objectEntries(obj).map(([key, value]) => [key, fn(value, key)])) as Record<keyof T, R>;
|
||||||
|
|
||||||
|
export const arrayToObject = <T extends string | number | symbol, R>(array: readonly T[], fn: (value: T, index: number) => R) =>
|
||||||
|
Object.fromEntries(array.map((value, index) => [value, fn(value, index)])) as Record<T, R>;
|
||||||
|
|
||||||
|
export const objectFilter = <T extends object>(obj: T, fn: (value: T[keyof T], key: keyof T) => boolean) =>
|
||||||
|
Object.fromEntries(objectEntries(obj).filter(([key, value]) => fn(value, key))) as T;
|
||||||
|
|
||||||
|
export const getOrSet = <M extends Map<unknown, unknown>>(
|
||||||
|
map: M,
|
||||||
|
key: Parameters<M['has']>[0],
|
||||||
|
value: Parameters<M['set']>[1],
|
||||||
|
): Parameters<M['set']>[1] => {
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, value);
|
||||||
|
}
|
||||||
|
return map.get(key)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function forEachParallel<T>(
|
||||||
|
iterable: AsyncIterable<T> | Promise<Iterable<T>>,
|
||||||
|
callback: (value: T) => Promise<void>,
|
||||||
|
) {
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
for await (const value of await iterable) {
|
||||||
|
promises.push(callback(value));
|
||||||
|
}
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { isEqual } from 'es-toolkit';
|
||||||
|
|
||||||
|
import type { MaimaiMajorVersionId, MaimaiRegion, MetadataMaybeRegionalized, MetadataMaybeVersioned } from '../interfaces';
|
||||||
|
import { objectEntries, objectFromEntries, objectMap } from './base';
|
||||||
|
import { zCoerceString } from './zod';
|
||||||
|
|
||||||
|
export const parseNetOpenDate = (input: unknown) => {
|
||||||
|
const str = zCoerceString(input);
|
||||||
|
const match = str.match(/^Net(\d{2})(\d{2})(\d{2})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, year, month, day] = match;
|
||||||
|
if (year === '99') return null;
|
||||||
|
return `20${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VersionedMap<T> = Partial<Record<MaimaiMajorVersionId, T>>;
|
||||||
|
export const maybeCompactVersionedMap = <T>(versionedMap: VersionedMap<T>): MetadataMaybeVersioned<T> => {
|
||||||
|
const versionedEntries = objectEntries(versionedMap)
|
||||||
|
.toSorted(([verA], [verB]) => verA - verB) // version: low -> high
|
||||||
|
.filter(([, data], index, array) => index === 0 || !isEqual(data, array[index - 1]?.[1])); // Skip entries that are equal to the previous version.
|
||||||
|
return versionedEntries.length === 1
|
||||||
|
? { unversioned: versionedEntries[0]![1]! }
|
||||||
|
: { versioned: objectFromEntries(versionedEntries) };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegionalizedMap<T> = Partial<Record<MaimaiRegion, VersionedMap<T>>>;
|
||||||
|
export const maybeCompactRegionalizedMap = <T>(regionalizedMap: RegionalizedMap<T>): MetadataMaybeRegionalized<T> => {
|
||||||
|
const regionalizedEntries = objectMap(regionalizedMap, versionedMap => maybeCompactVersionedMap(versionedMap!));
|
||||||
|
return regionalizedEntries.JPN != null && isEqual(regionalizedEntries.JPN, regionalizedEntries.EXP) && isEqual(regionalizedEntries.JPN, regionalizedEntries.CHN)
|
||||||
|
? { unregionalized: regionalizedEntries.JPN! }
|
||||||
|
: { regionalized: regionalizedEntries };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegionalizedNetOpenDate = Partial<Record<MaimaiRegion, string | null>>;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { MaimaiMajorVersionId } from '../interfaces';
|
||||||
|
import { MaimaiRegion } from '../interfaces';
|
||||||
|
|
||||||
|
export const forEachRegion = <T>(
|
||||||
|
regionMap: Map<MaimaiRegion, T>,
|
||||||
|
order: 'jpnFirst' | 'jpnLast',
|
||||||
|
callback: (region: MaimaiRegion, data: T) => void,
|
||||||
|
) =>
|
||||||
|
[...regionMap.entries()]
|
||||||
|
.sort(
|
||||||
|
([a], [b]) =>
|
||||||
|
(order === 'jpnFirst' ? 1 : -1) *
|
||||||
|
(Object.values(MaimaiRegion).indexOf(a) - Object.values(MaimaiRegion).indexOf(b)),
|
||||||
|
)
|
||||||
|
.forEach(([region, data]) => callback(region, data));
|
||||||
|
|
||||||
|
export const forEachVersion = <T>(
|
||||||
|
versionMap: Map<MaimaiMajorVersionId, T>,
|
||||||
|
order: 'oldFirst' | 'newFirst',
|
||||||
|
callback: (version: MaimaiMajorVersionId, data: T) => void,
|
||||||
|
) =>
|
||||||
|
[...versionMap.entries()]
|
||||||
|
.sort(([a], [b]) => (order === 'oldFirst' ? a - b : b - a))
|
||||||
|
.forEach(([version, data]) => callback(version, data));
|
||||||
|
|
||||||
|
export const forEachRegionAndVersion = <T>(
|
||||||
|
regionMap: Map<MaimaiRegion, Map<MaimaiMajorVersionId, T>>,
|
||||||
|
regionOrder: 'jpnFirst' | 'jpnLast',
|
||||||
|
versionOrder: 'oldFirst' | 'newFirst',
|
||||||
|
callback: (region: MaimaiRegion, version: MaimaiMajorVersionId, data: T) => void,
|
||||||
|
) => forEachRegion(
|
||||||
|
regionMap, regionOrder,
|
||||||
|
(region, versionMap) => forEachVersion(
|
||||||
|
versionMap, versionOrder,
|
||||||
|
(version, data) =>
|
||||||
|
callback(region, version, data),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
|
import { glob } from 'glob';
|
||||||
|
|
||||||
|
export async function* parseXmls(stream: ReturnType<typeof glob.stream>) {
|
||||||
|
for await (const file of stream) {
|
||||||
|
const fileName = typeof file === 'string' ? file : file.fullpath();
|
||||||
|
const xmlString = await fs.promises.readFile(fileName, 'utf-8');
|
||||||
|
const xml = new XMLParser({ ignoreAttributes: false, allowBooleanAttributes: true }).parse(xmlString);
|
||||||
|
yield { fileName, xml };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const globAxxxDirs = async (streamingAssetsPath: string) =>
|
||||||
|
(await glob(`${streamingAssetsPath}/*`)).sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
export const globFiles = (axxxPath: string, dirName: string, subDirPrefix: string, fileName: string) =>
|
||||||
|
glob.stream(`${axxxPath}/${dirName}/${subDirPrefix}*/${fileName}`);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { util as zUtil } from 'zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const zCoerceNumber = (data: unknown) => z.coerce.number().parse(data);
|
||||||
|
export const zCoerceString = (data: unknown) => z.coerce.string().parse(data);
|
||||||
|
export const zParseEnum = <T extends zUtil.EnumLike>(enumType: T, data: unknown) => z.enum(enumType).parse(data);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
import type { MaimaiMetadataKind, MaimaiRegion, MaimaiMajorVersionId } from './interfaces';
|
||||||
|
import { createLogger } from './logger';
|
||||||
|
import { basicDataTypes } from './processors/basic';
|
||||||
|
import { processMusic } from './processors/music';
|
||||||
|
import { objectMap } from './utils/base';
|
||||||
|
import { globAxxxDirs } from './utils/fs';
|
||||||
|
|
||||||
|
const logger = createLogger('Worker');
|
||||||
|
|
||||||
|
export interface WorkerArguments {
|
||||||
|
region: MaimaiRegion;
|
||||||
|
version: MaimaiMajorVersionId;
|
||||||
|
streamingAssetsPath: string;
|
||||||
|
metadataKind: MaimaiMetadataKind;
|
||||||
|
outputFilePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface WorkerContext extends WorkerArguments {
|
||||||
|
forEachAxxxDirOrdered: (callback: (axxxDir: string) => Promise<void>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkerProcessor<TIntermediateData> = (ctx: WorkerContext) => TIntermediateData | Promise<TIntermediateData>;
|
||||||
|
|
||||||
|
const run = async (args: WorkerArguments) => {
|
||||||
|
const { region, version, streamingAssetsPath, metadataKind, outputFilePath } = args;
|
||||||
|
logger.log(`Generating metadata for maimai (region = ${region}, version = ${version})`);
|
||||||
|
|
||||||
|
// Axxx dirs are ordered, so don't use forEachParallel.
|
||||||
|
const ctx: WorkerContext = {
|
||||||
|
...args,
|
||||||
|
forEachAxxxDirOrdered: async (callback: (axxxDir: string) => Promise<void>) => {
|
||||||
|
for (const axxxDir of await globAxxxDirs(streamingAssetsPath)) {
|
||||||
|
logger.log(`Processing ${axxxDir}`);
|
||||||
|
await callback(axxxDir);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadataKindProcessors: Record<MaimaiMetadataKind, WorkerProcessor<unknown>> = {
|
||||||
|
...objectMap(basicDataTypes, dataType => dataType.process),
|
||||||
|
music: processMusic,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.log(`Writing metadata to ${outputFilePath}`);
|
||||||
|
await fs.promises.writeFile(outputFilePath, JSON.stringify(await metadataKindProcessors[metadataKind](ctx), null, 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async (args: WorkerArguments) => {
|
||||||
|
try {
|
||||||
|
await run(args);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error processing metadata: ${error}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"strict": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user