diff --git a/README.md b/README.md index 05a93c7..cbfe365 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,94 @@ Useful workflow flags: `install.mjs` is idempotent: re-running it keeps any `*.json` config files that already exist and only patches the URL keys you pass on the CLI. +## Splitting gamedata + +The renderer can load gamedata files (FigureData, FurnitureData, FigureMap, +EffectMap, ProductData, HabboAvatarActions, ExternalTexts, UITexts) either as +a single legacy JSON/JSON5 file or as a **directory of small files** organised +in three tiers: `core/` (vendor baseline), `custom/` (your additions / overrides), +`seasonal/` (date-bound content such as Christmas or Easter). + +The split layout is much easier to maintain — you edit a small focused file +instead of a 43 MB FurnitureData.json — and lets you keep vendor and operator +content cleanly separated. + +### Directory layout + +``` +nitro-assets/gamedata/furnidata/ + manifest.json5 # { "tiers": ["core", "custom", "seasonal"] } + core/ + manifest.json5 # { "files": ["floor-001.json5", ..., "wall-001.json5"] } + floor-001.json5 + floor-002.json5 + wall-001.json5 + custom/ # OPTIONAL — created by you + manifest.json5 # { "files": ["my-rares.json5"] } + my-rares.json5 + seasonal/ # OPTIONAL — created by you + manifest.json5 + xmas-2026.json5 +``` + +Each tier is loaded in order. Within a tier, files load in the order listed in +its `manifest.json5`. Items in later layers override items in earlier layers +when they share the same identifier (`id`, `classname`, `name`, or the +top-level key for flat dictionaries). + +### Generating the `core/` tier from a legacy file + +Use the bundled CLI splitter: + +``` +node scripts/split-gamedata.mjs \ + --input ~/legacy-gamedata/FurnitureData.json \ + --output ~/nitro-assets/gamedata/furnidata +``` + +It auto-detects the gamedata type from the file's top-level keys and applies +the strategy that makes the most sense: + +| Type | Split strategy | +|----------------------|---------------------------------------------| +| EffectMap | one file per effect `type` (dance, fx, ...) | +| FigureData | one `palettes.json5` + one file per setType | +| FigureMap | chunks of `libraries` (default 500/file) | +| FurnitureData | floor / wall, chunks of `furnitype` (300) | +| HabboAvatarActions | grouped by `state` (or single file if ≤1) | +| ProductData | chunks of products (default 500) | +| ExternalTexts/UITexts| grouped by key prefix (e.g. `gamecenter.*`) | + +Useful flags: `--type=` to force the type, `--chunk-size=N` to override +the default chunk size, `--json` to emit standard JSON instead of JSON5, +`--force` to overwrite an existing output directory. Full reference: + +``` +node scripts/split-gamedata.mjs --help +``` + +We only ship the `core/` tier with vendor baselines — `custom/` and `seasonal/` +are operator-owned: create their manifests when you need them and the loader +picks them up automatically. + +### Pointing the renderer at a directory + +In `public/configuration/renderer-config.json`, replace the legacy file URL +with the directory URL (note the trailing slash — that's how the loader +detects split mode): + +```json5 +{ + // single file (legacy, still supported): + "furnidata.url": "https://example.com/nitro-assets/gamedata/FurnitureData.json", + + // directory (split mode): + "furnidata.url": "https://example.com/nitro-assets/gamedata/furnidata/", +} +``` + +Both styles work; you can migrate one gamedata file at a time. + ## Installation (manual) - First you should open terminal and navigate to the folder where you want to clone Nitro and Nitro-Renderer diff --git a/scripts/split-gamedata.mjs b/scripts/split-gamedata.mjs new file mode 100644 index 0000000..efeef7f --- /dev/null +++ b/scripts/split-gamedata.mjs @@ -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: + + / + manifest.json5 tier order (defaults to core/custom/seasonal) + core/ + manifest.json5 files list, in load order + .json5 + .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 --output [flags] + +Required: + --input Path to the legacy JSON (or JSON5) file + --output Target directory (will contain core/, manifest.json5) + +Optional: + --type Force the gamedata type (effectmap, figuredata, + figuremap, furnidata, avatar-actions, productdata, + external-texts, ui-texts). Default: auto-detect + --chunk-size 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=. 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); +}