mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
8200132b1f
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.
358 lines
14 KiB
JavaScript
358 lines
14 KiB
JavaScript
#!/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);
|
|
}
|