feat(scripts): add split-gamedata.mjs CLI splitter

Companion tool for the split-aware gamedata loader added in
@nitrots/utils. Takes a legacy single-file gamedata JSON/JSON5 and
produces the directory layout the loader expects:

  <out>/
    manifest.json5          (root manifest, tier order)
    core/
      manifest.json5        (file list in load order)
      <part>.json5 ...

The tool auto-detects the gamedata type from top-level keys and applies
the right split strategy:

- EffectMap            -> one file per effect type (dance, fx, ...)
- FigureData           -> palettes + one file per setType
- FigureMap            -> chunked libraries (default 500/file)
- FurnitureData        -> floor/wall, chunks of furnitype (default 300)
- HabboAvatarActions   -> grouped by state
- ProductData          -> chunked products (default 500)
- ExternalTexts/UITexts-> grouped by key prefix

Only the core/ tier is generated; custom/ and seasonal/ are operator-
owned and the loader auto-discovers them when their manifest.json5
exists.

Flags: --input, --output, --type, --chunk-size, --json (legacy emit),
--force, --help.

README extended with a 'Splitting gamedata' section covering the layout,
the strategy table, CLI usage and the renderer-config migration step.
This commit is contained in:
medievalshell
2026-05-18 21:20:13 +02:00
parent 0028b03b6a
commit 8200132b1f
2 changed files with 445 additions and 0 deletions
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { dirname, basename, resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HELP = `
Nitro V3 — gamedata splitter
Takes a legacy single-file gamedata JSON (EffectMap, FigureData, FigureMap,
FurnitureData, HabboAvatarActions, ProductData, ExternalTexts, UITexts) and
produces the directory layout consumed by the split-aware loader:
<output>/
manifest.json5 tier order (defaults to core/custom/seasonal)
core/
manifest.json5 files list, in load order
<part1>.json5
<part2>.json5
...
Custom and seasonal tiers are NOT generated — those are operator-owned and
will be auto-discovered by the loader if their manifest.json5 exists.
Usage:
node scripts/split-gamedata.mjs --input <file> --output <dir> [flags]
Required:
--input <path> Path to the legacy JSON (or JSON5) file
--output <dir> Target directory (will contain core/, manifest.json5)
Optional:
--type <name> Force the gamedata type (effectmap, figuredata,
figuremap, furnidata, avatar-actions, productdata,
external-texts, ui-texts). Default: auto-detect
--chunk-size <N> Items per chunk for the bucket-based splits.
Defaults: figuremap=500, furnidata=300, productdata=500,
external-texts/ui-texts split by prefix instead
--json (or --legacy) Emit standard JSON instead of JSON5 (no comments)
--force Overwrite the output directory if it already exists
--help, -h Show this help
Examples:
node scripts/split-gamedata.mjs \\
--input ~/gamedata/FurnitureData.json \\
--output ~/nitro-assets/gamedata/furnidata
node scripts/split-gamedata.mjs \\
--input ./EffectMap.json --output ./effectmap --chunk-size 50
After splitting, point your renderer-config at the directory (note the
trailing slash):
"furnidata.url": "https://example.com/nitro-assets/gamedata/furnidata/"
`;
const args = process.argv.slice(2);
const opts = {
input: null,
output: null,
type: null,
chunkSize: null,
asJson5: true,
force: false,
help: false
};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--help' || a === '-h') opts.help = true;
else if (a === '--input') opts.input = args[++i];
else if (a === '--output') opts.output = args[++i];
else if (a === '--type') opts.type = args[++i];
else if (a === '--chunk-size') opts.chunkSize = parseInt(args[++i], 10);
else if (a === '--json' || a === '--legacy') opts.asJson5 = false;
else if (a === '--json5') opts.asJson5 = true;
else if (a === '--force') opts.force = true;
else if (a.startsWith('--input=')) opts.input = a.slice('--input='.length);
else if (a.startsWith('--output=')) opts.output = a.slice('--output='.length);
else if (a.startsWith('--type=')) opts.type = a.slice('--type='.length);
else if (a.startsWith('--chunk-size=')) opts.chunkSize = parseInt(a.slice('--chunk-size='.length), 10);
else {
process.stderr.write(`Unknown flag: ${ a }\n`);
process.exit(2);
}
}
if (opts.help) {
process.stdout.write(HELP);
process.exit(0);
}
if (!opts.input || !opts.output) {
process.stderr.write('Missing --input or --output. Use --help for usage.\n');
process.exit(2);
}
if (!existsSync(opts.input)) {
process.stderr.write(`Input file not found: ${ opts.input }\n`);
process.exit(1);
}
const detectType = (data) => {
if (!data || typeof data !== 'object') return null;
const keys = new Set(Object.keys(data));
if (keys.has('roomitemtypes') || keys.has('wallitemtypes')) return 'furnidata';
if (keys.has('palettes') && keys.has('setTypes')) return 'figuredata';
if (keys.has('libraries')) return 'figuremap';
if (keys.has('effects')) return 'effectmap';
if (keys.has('actions')) return 'avatar-actions';
if (keys.has('productdata')) return 'productdata';
// Flat dict heuristic: many top-level scalar keys with dots → texts
if (keys.size > 30 && Object.values(data).every(v => typeof v === 'string')) {
const sampleKey = Object.keys(data)[0] || '';
return sampleKey.includes('.') ? 'external-texts' : 'ui-texts';
}
return null;
};
const splitArrayInChunks = (arr, size) => {
const chunks = [];
for (let i = 0; i < arr.length; i += size) chunks.push(arr.slice(i, i + size));
return chunks;
};
const splitByPrefix = (dict, maxPerBucket = 800) => {
const buckets = new Map();
for (const key of Object.keys(dict)) {
const prefix = key.split('.')[0] || '_other';
if (!buckets.has(prefix)) buckets.set(prefix, {});
buckets.get(prefix)[key] = dict[key];
}
// Merge small buckets into a shared one to reduce manifest noise
const out = [];
const small = {};
let smallCount = 0;
for (const [name, content] of buckets) {
const size = Object.keys(content).length;
if (size < 50) {
Object.assign(small, content);
smallCount += size;
} else {
// split further if oversized
if (size > maxPerBucket) {
const entries = Object.entries(content);
const slices = splitArrayInChunks(entries, maxPerBucket);
slices.forEach((slice, idx) => {
const part = {};
for (const [k, v] of slice) part[k] = v;
out.push([ `${ name }-${ String(idx + 1).padStart(3, '0') }`, part ]);
});
} else {
out.push([ name, content ]);
}
}
}
if (smallCount > 0) out.push([ '_misc', small ]);
return out;
};
const sanitizeName = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
const ext = () => opts.asJson5 ? 'json5' : 'json';
const writePart = (filePath, data, headerComment) => {
const body = JSON.stringify(data, null, 4);
const out = opts.asJson5 && headerComment
? `// ${ headerComment }\n${ body }\n`
: `${ body }\n`;
writeFileSync(filePath, out, 'utf8');
};
const buildSplits = (type, data) => {
switch (type) {
case 'effectmap': {
const effects = data.effects || [];
const byType = new Map();
for (const e of effects) {
const t = sanitizeName(e.type || 'other');
if (!byType.has(t)) byType.set(t, []);
byType.get(t).push(e);
}
return Array.from(byType, ([ t, items ]) => ({
name: `effects-${ t }.${ ext() }`,
comment: `Effects of type "${ t }" (${ items.length } items)`,
content: { effects: items }
}));
}
case 'figuredata': {
const parts = [];
if (data.palettes?.length) {
parts.push({
name: `palettes.${ ext() }`,
comment: `Color palettes (${ data.palettes.length })`,
content: { palettes: data.palettes }
});
}
for (const st of (data.setTypes || [])) {
const t = sanitizeName(st.type);
parts.push({
name: `settype-${ t }.${ ext() }`,
comment: `setType "${ st.type }" (paletteId=${ st.paletteId })`,
content: { setTypes: [ st ] }
});
}
return parts;
}
case 'figuremap': {
const libs = data.libraries || [];
const size = opts.chunkSize || 500;
const chunks = splitArrayInChunks(libs, size);
return chunks.map((chunk, idx) => ({
name: `libraries-${ String(idx + 1).padStart(3, '0') }.${ ext() }`,
comment: `libraries ${ idx * size + 1 }..${ idx * size + chunk.length } of ${ libs.length }`,
content: { libraries: chunk }
}));
}
case 'furnidata': {
const parts = [];
const size = opts.chunkSize || 300;
const floor = data.roomitemtypes?.furnitype || [];
const wall = data.wallitemtypes?.furnitype || [];
const floorChunks = splitArrayInChunks(floor, size);
floorChunks.forEach((chunk, idx) => {
parts.push({
name: `floor-${ String(idx + 1).padStart(3, '0') }.${ ext() }`,
comment: `Floor furniture ${ idx * size + 1 }..${ idx * size + chunk.length } of ${ floor.length }`,
content: { roomitemtypes: { furnitype: chunk } }
});
});
const wallChunks = splitArrayInChunks(wall, size);
wallChunks.forEach((chunk, idx) => {
parts.push({
name: `wall-${ String(idx + 1).padStart(3, '0') }.${ ext() }`,
comment: `Wall furniture ${ idx * size + 1 }..${ idx * size + chunk.length } of ${ wall.length }`,
content: { wallitemtypes: { furnitype: chunk } }
});
});
return parts;
}
case 'avatar-actions': {
const actions = data.actions || [];
const byState = new Map();
for (const a of actions) {
const s = sanitizeName(a.state || 'other');
if (!byState.has(s)) byState.set(s, []);
byState.get(s).push(a);
}
if (byState.size <= 1) {
return [ {
name: `actions.${ ext() }`,
comment: `All avatar actions (${ actions.length })`,
content: { actions }
} ];
}
return Array.from(byState, ([ s, items ]) => ({
name: `actions-${ s }.${ ext() }`,
comment: `Actions in state "${ s }" (${ items.length })`,
content: { actions: items }
}));
}
case 'productdata': {
const products = data.productdata?.product || [];
const size = opts.chunkSize || 500;
const chunks = splitArrayInChunks(products, size);
return chunks.map((chunk, idx) => ({
name: `products-${ String(idx + 1).padStart(3, '0') }.${ ext() }`,
comment: `Products ${ idx * size + 1 }..${ idx * size + chunk.length } of ${ products.length }`,
content: { productdata: { product: chunk } }
}));
}
case 'external-texts':
case 'ui-texts': {
const buckets = splitByPrefix(data, opts.chunkSize || 800);
return buckets.map(([ name, content ]) => ({
name: `${ sanitizeName(name) }.${ ext() }`,
comment: `${ name } (${ Object.keys(content).length } keys)`,
content
}));
}
default:
throw new Error(`Unknown gamedata type: ${ type }. Use --type to force one.`);
}
};
const main = () => {
const raw = readFileSync(opts.input, 'utf8');
let data;
try {
data = JSON.parse(raw);
} catch {
try {
const JSON5 = require('json5');
data = JSON5.parse(raw);
} catch (e) {
process.stderr.write(`Could not parse ${ opts.input } as JSON nor JSON5: ${ e.message }\n`);
process.exit(1);
}
}
const type = opts.type || detectType(data);
if (!type) {
process.stderr.write('Could not auto-detect gamedata type. Pass --type=<name>. See --help.\n');
process.exit(1);
}
const outDir = resolve(opts.output);
const coreDir = join(outDir, 'core');
if (existsSync(outDir)) {
if (!opts.force) {
process.stderr.write(`Output directory already exists: ${ outDir }. Use --force to overwrite.\n`);
process.exit(1);
}
rmSync(outDir, { recursive: true, force: true });
}
mkdirSync(coreDir, { recursive: true });
const parts = buildSplits(type, data);
if (!parts.length) {
process.stderr.write(`No content produced for type ${ type }. Input may be empty.\n`);
process.exit(1);
}
for (const part of parts) {
writePart(join(coreDir, part.name), part.content, part.comment);
}
const coreManifest = {
files: parts.map(p => p.name)
};
const coreManifestBody = opts.asJson5
? `// Auto-generated by split-gamedata.mjs from ${ basename(opts.input) }\n// Type: ${ type }${ parts.length } files, ${ parts.reduce((n, p) => n + JSON.stringify(p.content).length, 0).toLocaleString() } chars total\n${ JSON.stringify(coreManifest, null, 4) }\n`
: `${ JSON.stringify(coreManifest, null, 4) }\n`;
writeFileSync(join(coreDir, `manifest.${ ext() }`), coreManifestBody, 'utf8');
const rootManifest = { tiers: [ 'core', 'custom', 'seasonal' ] };
const rootManifestBody = opts.asJson5
? `// Root manifest — load order of tiers (later overrides earlier by id/classname).\n// Drop a custom/manifest.${ ext() } and/or seasonal/manifest.${ ext() } to add\n// override tiers without touching core/.\n${ JSON.stringify(rootManifest, null, 4) }\n`
: `${ JSON.stringify(rootManifest, null, 4) }\n`;
writeFileSync(join(outDir, `manifest.${ ext() }`), rootManifestBody, 'utf8');
process.stdout.write([
`[split-gamedata] ${ type } -> ${ outDir }`,
` ${ parts.length } file(s) in core/`,
` tiers: core (always loaded), custom (optional), seasonal (optional)`,
` point renderer-config at: ${ outDir.replace(/\\/g, '/') }/`,
''
].join('\n'));
};
try {
main();
} catch (e) {
process.stderr.write(`[split-gamedata] ${ e.message }\n`);
process.exit(1);
}