You've already forked Nitro_Render_V3
mirror of
https://github.com/duckietm/Nitro_Render_V3.git
synced 2026-06-19 15:06:20 +00:00
merge: integrate duckietm/Dev (JSON5 + split-aware gamedata loader)
# Conflicts: # packages/session/src/SessionDataManager.ts # yarn.lock
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { IAssetData, IAssetManager, IGraphicAsset, IGraphicAssetCollection } from '@nitrots/api';
|
||||
import { NitroBundle, NitroLogger } from '@nitrots/utils';
|
||||
import { NitroBundle, NitroLogger, parseConfigJsonFromResponse } from '@nitrots/utils';
|
||||
import { AnimatedGIF } from '@pixi/gif';
|
||||
import { Assets, Spritesheet, SpritesheetData, Texture } from 'pixi.js';
|
||||
import { GraphicAssetCollection } from './GraphicAssetCollection';
|
||||
@@ -159,7 +159,7 @@ export class AssetManager implements IAssetManager
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(url.endsWith('.json'))
|
||||
else if(url.endsWith('.json') || url.endsWith('.json5'))
|
||||
{
|
||||
let response: Response;
|
||||
|
||||
@@ -178,18 +178,18 @@ export class AssetManager implements IAssetManager
|
||||
|
||||
try
|
||||
{
|
||||
data = await response.json() as IAssetData;
|
||||
data = await parseConfigJsonFromResponse<IAssetData>(response, url);
|
||||
}
|
||||
catch(parseErr)
|
||||
{
|
||||
throw new Error(`Invalid JSON in "${ url }" — the URL may be wrong and returning an HTML page instead of JSON (${ parseErr.message })`);
|
||||
throw new Error(`Invalid asset data "${ url }" — JSON/JSON5 parse failed (${ parseErr.message })`);
|
||||
}
|
||||
|
||||
let texture: Texture = null;
|
||||
const imagePath = data?.spritesheet?.meta?.image;
|
||||
const fallbackImagePath = ((data?.name && data.name.length > 0)
|
||||
? `${data.name}.png`
|
||||
: url.replace(/\.json$/i, '.png'));
|
||||
: url.replace(/\.json5?$/i, '.png'));
|
||||
const resolvedImageUrl = (imagePath
|
||||
? new URL(imagePath, url).toString()
|
||||
: new URL(fallbackImagePath, url).toString());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IAssetManager, IAvatarFigureContainer, IAvatarImageListener } from '@nitrots/api';
|
||||
import { GetConfiguration } from '@nitrots/configuration';
|
||||
import { AvatarRenderLibraryEvent, GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
|
||||
import { parseConfigJsonFromResponse } from '@nitrots/utils';
|
||||
import { AvatarAssetDownloadLibrary } from './AvatarAssetDownloadLibrary';
|
||||
import { AvatarStructure } from './AvatarStructure';
|
||||
|
||||
@@ -57,11 +58,11 @@ export class AvatarAssetDownloadManager
|
||||
|
||||
try
|
||||
{
|
||||
responseData = await response.json();
|
||||
responseData = await parseConfigJsonFromResponse(response, url);
|
||||
}
|
||||
catch(parseErr)
|
||||
{
|
||||
throw new Error(`Invalid JSON in figure map "${ url }" — the URL may be wrong. Check "avatar.figuremap.url" in renderer-config.json (${ parseErr.message })`);
|
||||
throw new Error(`Invalid figure map "${ url }" — JSON/JSON5 parse failed. Check "avatar.figuremap.url" in renderer-config.json (${ parseErr.message })`);
|
||||
}
|
||||
|
||||
this.processFigureMap(responseData.libraries);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AvatarSetType, IAssetManager, IAvatarEffectListener, IAvatarFigureConta
|
||||
import { GetAssetManager } from '@nitrots/assets';
|
||||
import { GetConfiguration } from '@nitrots/configuration';
|
||||
import { GetEventDispatcher, NitroEventType } from '@nitrots/events';
|
||||
import { loadGamedata } from '@nitrots/utils';
|
||||
import { AvatarAssetDownloadManager } from './AvatarAssetDownloadManager';
|
||||
import { AvatarFigureContainer } from './AvatarFigureContainer';
|
||||
import { AvatarImage } from './AvatarImage';
|
||||
@@ -72,26 +73,13 @@ export class AvatarRenderManager implements IAvatarRenderManager
|
||||
|
||||
if(!url || !url.length) throw new Error('Missing "avatar.actions.url" in config — add the URL to your renderer-config.json');
|
||||
|
||||
let response: Response;
|
||||
|
||||
try
|
||||
{
|
||||
response = await fetch(url);
|
||||
this._structure.updateActions(await loadGamedata(url));
|
||||
}
|
||||
catch(fetchErr)
|
||||
catch(err)
|
||||
{
|
||||
throw new Error(`Could not fetch avatar actions from "${ url }" — check "avatar.actions.url" in renderer-config.json (${ fetchErr.message })`);
|
||||
}
|
||||
|
||||
if(response.status !== 200) throw new Error(`Failed to load avatar actions from "${ url }" — server returned HTTP ${ response.status }. Check "avatar.actions.url" in renderer-config.json`);
|
||||
|
||||
try
|
||||
{
|
||||
this._structure.updateActions(await response.json());
|
||||
}
|
||||
catch(parseErr)
|
||||
{
|
||||
throw new Error(`Invalid JSON from "${ url }" — the URL may be wrong and returning an HTML page instead of JSON. Check "avatar.actions.url" in renderer-config.json (${ parseErr.message })`);
|
||||
throw new Error(`Could not load avatar actions from "${ url }" — check "avatar.actions.url" in renderer-config.json (${ err?.message || err })`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,26 +93,13 @@ export class AvatarRenderManager implements IAvatarRenderManager
|
||||
|
||||
if(!url || !url.length) throw new Error('Missing "avatar.figuredata.url" in config — add the URL to your renderer-config.json');
|
||||
|
||||
let response: Response;
|
||||
|
||||
try
|
||||
{
|
||||
response = await fetch(url);
|
||||
this._structure.figureData.appendJSON(await loadGamedata(url));
|
||||
}
|
||||
catch(fetchErr)
|
||||
catch(err)
|
||||
{
|
||||
throw new Error(`Could not fetch figure data from "${ url }" — check "avatar.figuredata.url" in renderer-config.json (${ fetchErr.message })`);
|
||||
}
|
||||
|
||||
if(response.status !== 200) throw new Error(`Failed to load figure data from "${ url }" — server returned HTTP ${ response.status }. Check "avatar.figuredata.url" in renderer-config.json`);
|
||||
|
||||
try
|
||||
{
|
||||
this._structure.figureData.appendJSON(await response.json());
|
||||
}
|
||||
catch(parseErr)
|
||||
{
|
||||
throw new Error(`Invalid JSON from "${ url }" — the URL may be wrong and returning an HTML page instead of JSON. Check "avatar.figuredata.url" in renderer-config.json (${ parseErr.message })`);
|
||||
throw new Error(`Could not load figure data from "${ url }" — check "avatar.figuredata.url" in renderer-config.json (${ err?.message || err })`);
|
||||
}
|
||||
|
||||
this._structure.init();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IAssetManager, IAvatarEffectListener } from '@nitrots/api';
|
||||
import { GetConfiguration } from '@nitrots/configuration';
|
||||
import { AvatarRenderEffectLibraryEvent, GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
|
||||
import { loadGamedata } from '@nitrots/utils';
|
||||
import { AvatarStructure } from './AvatarStructure';
|
||||
import { EffectAssetDownloadLibrary } from './EffectAssetDownloadLibrary';
|
||||
|
||||
@@ -31,28 +32,15 @@ export class EffectAssetDownloadManager
|
||||
|
||||
if(!url || !url.length) throw new Error('Missing "avatar.effectmap.url" in config — add the effect map URL to your renderer-config.json');
|
||||
|
||||
let response: Response;
|
||||
|
||||
try
|
||||
{
|
||||
response = await fetch(url);
|
||||
}
|
||||
catch(fetchErr)
|
||||
{
|
||||
throw new Error(`Could not fetch effect map from "${ url }" — check "avatar.effectmap.url" in renderer-config.json (${ fetchErr.message })`);
|
||||
}
|
||||
|
||||
if(response.status !== 200) throw new Error(`Failed to load effect map from "${ url }" — server returned HTTP ${ response.status }. Check "avatar.effectmap.url" in renderer-config.json`);
|
||||
|
||||
let responseData: any;
|
||||
|
||||
try
|
||||
{
|
||||
responseData = await response.json();
|
||||
responseData = await loadGamedata(url);
|
||||
}
|
||||
catch(parseErr)
|
||||
catch(err)
|
||||
{
|
||||
throw new Error(`Invalid JSON in effect map "${ url }" — the URL may be wrong. Check "avatar.effectmap.url" in renderer-config.json (${ parseErr.message })`);
|
||||
throw new Error(`Could not load effect map from "${ url }" — check "avatar.effectmap.url" in renderer-config.json (${ err?.message || err })`);
|
||||
}
|
||||
|
||||
this.processEffectMap(responseData.effects);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NitroLogger, NitroVersion } from '@nitrots/utils';
|
||||
import { NitroLogger, NitroVersion, parseConfigJsonFromResponse } from '@nitrots/utils';
|
||||
import { IConfigurationManager } from './IConfigurationManager';
|
||||
|
||||
export class ConfigurationManager implements IConfigurationManager
|
||||
@@ -54,11 +54,11 @@ export class ConfigurationManager implements IConfigurationManager
|
||||
|
||||
try
|
||||
{
|
||||
json = await response.json();
|
||||
json = await parseConfigJsonFromResponse(response, url);
|
||||
}
|
||||
catch(parseError)
|
||||
{
|
||||
throw new Error(`Invalid JSON in config "${ url }" — check for syntax errors like trailing commas or missing quotes (${ parseError.message })`);
|
||||
throw new Error(`Invalid config "${ url }" — JSON/JSON5 parse failed. JSON5 allows comments, trailing commas and unquoted keys (${ parseError.message })`);
|
||||
}
|
||||
|
||||
this.parseConfiguration(json);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@nitrots/communication": "1.0.0",
|
||||
"@nitrots/configuration": "1.0.0",
|
||||
"@nitrots/events": "1.0.0",
|
||||
"@nitrots/utils": "1.0.0",
|
||||
"pixi.js": "^8.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ILocalizationManager } from '@nitrots/api';
|
||||
import { BadgePointLimitsEvent, GetCommunication } from '@nitrots/communication';
|
||||
import { GetConfiguration } from '@nitrots/configuration';
|
||||
import { loadGamedata } from '@nitrots/utils';
|
||||
import { BadgeBaseAndLevel } from './BadgeBaseAndLevel';
|
||||
|
||||
export class LocalizationManager implements ILocalizationManager
|
||||
@@ -25,28 +26,15 @@ export class LocalizationManager implements ILocalizationManager
|
||||
|
||||
url = GetConfiguration().interpolate(url);
|
||||
|
||||
let response: Response;
|
||||
|
||||
try
|
||||
{
|
||||
response = await fetch(url);
|
||||
}
|
||||
catch(fetchErr)
|
||||
{
|
||||
throw new Error(`Could not fetch localization file "${ url }" — check "external.texts.url" in ui-config.json (${ fetchErr.message })`);
|
||||
}
|
||||
|
||||
if(response.status !== 200) throw new Error(`Failed to load localization file "${ url }" — server returned HTTP ${ response.status }. Check "external.texts.url" in ui-config.json`);
|
||||
|
||||
let data: any;
|
||||
|
||||
try
|
||||
{
|
||||
data = await response.json();
|
||||
data = await loadGamedata(url);
|
||||
}
|
||||
catch(parseErr)
|
||||
catch(err)
|
||||
{
|
||||
throw new Error(`Invalid JSON in localization file "${ url }" — the URL may be wrong. Check "external.texts.url" in ui-config.json (${ parseErr.message })`);
|
||||
throw new Error(`Could not load localization file "${ url }" — check "external.texts.url" in ui-config.json (${ err?.message || err })`);
|
||||
}
|
||||
|
||||
this.parseLocalization(data);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@nitrots/configuration": "1.0.0",
|
||||
"@nitrots/events": "1.0.0",
|
||||
"@nitrots/localization": "1.0.0",
|
||||
"@nitrots/utils": "1.0.0",
|
||||
"pixi.js": "^8.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AccountSafetyLockStatusChangeMessageEvent, AccountSafetyLockStatusChang
|
||||
import { GetConfiguration } from '@nitrots/configuration';
|
||||
import { GetLocalizationManager } from '@nitrots/localization';
|
||||
import { GetEventDispatcher, MysteryBoxKeysUpdateEvent, NitroEvent, NitroEventType, NitroSettingsEvent, SessionDataPreferencesEvent, UserNameUpdateEvent } from '@nitrots/events';
|
||||
import { CreateLinkEvent, HabboWebTools } from '@nitrots/utils';
|
||||
import { CreateLinkEvent, HabboWebTools, parseConfigJsonFromResponse } from '@nitrots/utils';
|
||||
import { Texture } from 'pixi.js';
|
||||
import { GroupInformationManager } from './GroupInformationManager';
|
||||
import { IgnoredUsersManager } from './IgnoredUsersManager';
|
||||
@@ -194,7 +194,7 @@ export class SessionDataManager implements ISessionDataManager
|
||||
|
||||
if(response.status !== 200) throw new Error(`Unable to load ${ url }`);
|
||||
|
||||
const data = await response.json();
|
||||
const data = await parseConfigJsonFromResponse(response, url);
|
||||
|
||||
this._floorItemOverrides = this.parseFurnitureOverrides(data?.roomitemtypes?.furnitype || []);
|
||||
this._wallItemOverrides = this.parseFurnitureOverrides(data?.wallitemtypes?.furnitype || []);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FurnitureType, IFurnitureData } from '@nitrots/api';
|
||||
import { GetConfiguration } from '@nitrots/configuration';
|
||||
import { GetLocalizationManager } from '@nitrots/localization';
|
||||
import { loadGamedata } from '@nitrots/utils';
|
||||
import { FurnitureData } from './FurnitureData';
|
||||
|
||||
export class FurnitureDataLoader
|
||||
@@ -20,28 +21,15 @@ export class FurnitureDataLoader
|
||||
|
||||
if(!url || !url.length) throw new Error('Missing "furnidata.url" in config — add the furniture data URL to your renderer-config.json');
|
||||
|
||||
let response: Response;
|
||||
|
||||
try
|
||||
{
|
||||
response = await fetch(url);
|
||||
}
|
||||
catch(fetchErr)
|
||||
{
|
||||
throw new Error(`Could not fetch furniture data from "${ url }" — check "furnidata.url" in renderer-config.json (${ fetchErr.message })`);
|
||||
}
|
||||
|
||||
if(response.status !== 200) throw new Error(`Failed to load furniture data from "${ url }" — server returned HTTP ${ response.status }. Check "furnidata.url" in renderer-config.json`);
|
||||
|
||||
let responseData: any;
|
||||
|
||||
try
|
||||
{
|
||||
responseData = await response.json();
|
||||
responseData = await loadGamedata(url);
|
||||
}
|
||||
catch(parseErr)
|
||||
catch(err)
|
||||
{
|
||||
throw new Error(`Invalid JSON in furniture data "${ url }" — the URL may be wrong. Check "furnidata.url" in renderer-config.json (${ parseErr.message })`);
|
||||
throw new Error(`Could not load furniture data from "${ url }" — check "furnidata.url" in renderer-config.json (${ err?.message || err })`);
|
||||
}
|
||||
|
||||
if(responseData.roomitemtypes) this.parseFloorItems(responseData.roomitemtypes);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IProductData } from '@nitrots/api';
|
||||
import { GetConfiguration } from '@nitrots/configuration';
|
||||
import { loadGamedata } from '@nitrots/utils';
|
||||
import { ProductData } from './ProductData';
|
||||
|
||||
export class ProductDataLoader
|
||||
@@ -17,28 +18,15 @@ export class ProductDataLoader
|
||||
|
||||
if(!url || !url.length) throw new Error('Missing "productdata.url" in config — add the product data URL to your renderer-config.json');
|
||||
|
||||
let response: Response;
|
||||
|
||||
try
|
||||
{
|
||||
response = await fetch(url);
|
||||
}
|
||||
catch(fetchErr)
|
||||
{
|
||||
throw new Error(`Could not fetch product data from "${ url }" — check "productdata.url" in renderer-config.json (${ fetchErr.message })`);
|
||||
}
|
||||
|
||||
if(response.status !== 200) throw new Error(`Failed to load product data from "${ url }" — server returned HTTP ${ response.status }. Check "productdata.url" in renderer-config.json`);
|
||||
|
||||
let responseData: any;
|
||||
|
||||
try
|
||||
{
|
||||
responseData = await response.json();
|
||||
responseData = await loadGamedata(url);
|
||||
}
|
||||
catch(parseErr)
|
||||
catch(err)
|
||||
{
|
||||
throw new Error(`Invalid JSON in product data "${ url }" — the URL may be wrong. Check "productdata.url" in renderer-config.json (${ parseErr.message })`);
|
||||
throw new Error(`Could not load product data from "${ url }" — check "productdata.url" in renderer-config.json (${ err?.message || err })`);
|
||||
}
|
||||
|
||||
this.parseProducts(responseData.productdata);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"main": "./index",
|
||||
"dependencies": {
|
||||
"@nitrots/api": "1.0.0",
|
||||
"json5": "^2.2.3",
|
||||
"pako": "^2.1.0",
|
||||
"pixi.js": "^8.8.1"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { fetchConfigJson } from './JsonParser';
|
||||
|
||||
export const DEFAULT_TIERS = [ 'core', 'custom', 'seasonal' ] as const;
|
||||
export type GamedataTier = typeof DEFAULT_TIERS[number] | string;
|
||||
|
||||
export interface GamedataLoadOptions
|
||||
{
|
||||
tiers?: readonly GamedataTier[];
|
||||
mergeArrayIdKeys?: readonly string[];
|
||||
}
|
||||
|
||||
const DEFAULT_ID_KEYS = [ 'id', 'classname', 'name' ] as const;
|
||||
|
||||
const looksLikeDirectory = (url: string): boolean =>
|
||||
{
|
||||
if(!url) return false;
|
||||
|
||||
const stripped = url.split('?')[0].split('#')[0];
|
||||
|
||||
return stripped.endsWith('/');
|
||||
};
|
||||
|
||||
const joinUrl = (base: string, path: string): string =>
|
||||
{
|
||||
const cleanBase = base.endsWith('/') ? base : `${ base }/`;
|
||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
|
||||
return `${ cleanBase }${ cleanPath }`;
|
||||
};
|
||||
|
||||
const tryFetchOrNull = async <T = any>(url: string): Promise<T | null> =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return await fetchConfigJson<T>(url);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isPlainObject = (value: any): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const arrayItemsLookKeyed = (arr: any[], idKeys: readonly string[]): string | null =>
|
||||
{
|
||||
if(!arr.length) return null;
|
||||
|
||||
for(const key of idKeys)
|
||||
{
|
||||
let allHave = true;
|
||||
|
||||
for(const item of arr)
|
||||
{
|
||||
if(!isPlainObject(item) || item[key] === undefined || item[key] === null)
|
||||
{
|
||||
allHave = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(allHave) return key;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAULT_ID_KEYS): any =>
|
||||
{
|
||||
if(b === undefined) return a;
|
||||
if(a === undefined) return b;
|
||||
|
||||
if(Array.isArray(a) && Array.isArray(b))
|
||||
{
|
||||
const idKey = arrayItemsLookKeyed(a, idKeys) || arrayItemsLookKeyed(b, idKeys);
|
||||
|
||||
if(!idKey) return a.concat(b);
|
||||
|
||||
const index = new Map<any, number>();
|
||||
const out: any[] = [];
|
||||
|
||||
for(const item of a)
|
||||
{
|
||||
index.set(item[idKey], out.length);
|
||||
out.push(item);
|
||||
}
|
||||
|
||||
for(const item of b)
|
||||
{
|
||||
const key = item[idKey];
|
||||
const at = index.get(key);
|
||||
|
||||
if(at !== undefined)
|
||||
{
|
||||
out[at] = mergeGamedata(out[at], item, idKeys);
|
||||
}
|
||||
else
|
||||
{
|
||||
index.set(key, out.length);
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
if(isPlainObject(a) && isPlainObject(b))
|
||||
{
|
||||
const out: Record<string, any> = { ...a };
|
||||
|
||||
for(const k of Object.keys(b))
|
||||
{
|
||||
out[k] = mergeGamedata(a[k], b[k], idKeys);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
return b;
|
||||
};
|
||||
|
||||
interface TierManifest
|
||||
{
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
interface RootManifest
|
||||
{
|
||||
tiers?: GamedataTier[];
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
export const loadGamedata = async <T = any>(url: string, options: GamedataLoadOptions = {}): Promise<T> =>
|
||||
{
|
||||
if(!url) throw new Error('loadGamedata: empty URL');
|
||||
|
||||
if(!looksLikeDirectory(url))
|
||||
{
|
||||
return await fetchConfigJson<T>(url);
|
||||
}
|
||||
|
||||
const idKeys = options.mergeArrayIdKeys ?? DEFAULT_ID_KEYS;
|
||||
const rootManifest = await tryFetchOrNull<RootManifest>(joinUrl(url, 'manifest.json5'))
|
||||
?? await tryFetchOrNull<RootManifest>(joinUrl(url, 'manifest.json'));
|
||||
|
||||
const tiers = (rootManifest?.tiers && rootManifest.tiers.length)
|
||||
? rootManifest.tiers
|
||||
: (options.tiers ?? DEFAULT_TIERS);
|
||||
|
||||
let merged: any = undefined;
|
||||
|
||||
if(rootManifest?.files?.length)
|
||||
{
|
||||
for(const file of rootManifest.files)
|
||||
{
|
||||
const fileUrl = joinUrl(url, file);
|
||||
const part = await fetchConfigJson(fileUrl);
|
||||
merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys);
|
||||
}
|
||||
}
|
||||
|
||||
for(const tier of tiers)
|
||||
{
|
||||
const tierUrl = joinUrl(url, `${ tier }/`);
|
||||
const tierManifest = await tryFetchOrNull<TierManifest>(joinUrl(tierUrl, 'manifest.json5'))
|
||||
?? await tryFetchOrNull<TierManifest>(joinUrl(tierUrl, 'manifest.json'));
|
||||
|
||||
if(!tierManifest?.files?.length) continue;
|
||||
|
||||
for(const file of tierManifest.files)
|
||||
{
|
||||
const fileUrl = joinUrl(tierUrl, file);
|
||||
const part = await fetchConfigJson(fileUrl);
|
||||
merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys);
|
||||
}
|
||||
}
|
||||
|
||||
if(merged === undefined) throw new Error(`loadGamedata: directory mode at "${ url }" produced no data — make sure at least one tier (core/custom/seasonal) has a manifest.json5 with a 'files' array`);
|
||||
|
||||
return merged as T;
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import JSON5 from 'json5';
|
||||
|
||||
declare const __NITRO_JSON_MODE__: 'legacy' | 'json5' | 'auto' | undefined;
|
||||
|
||||
const JSON5_EXTENSION = /\.json5(?:[?#]|$)/i;
|
||||
const JSON5_MIME = /(?:application|text)\/(?:json5|x-json5)/i;
|
||||
|
||||
const resolveJsonMode = (): 'legacy' | 'json5' | 'auto' =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if(typeof __NITRO_JSON_MODE__ !== 'undefined' && __NITRO_JSON_MODE__)
|
||||
{
|
||||
if(__NITRO_JSON_MODE__ === 'legacy' || __NITRO_JSON_MODE__ === 'json5' || __NITRO_JSON_MODE__ === 'auto') return __NITRO_JSON_MODE__;
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
|
||||
return 'auto';
|
||||
};
|
||||
|
||||
const looksLikeJson5Url = (url: string): boolean => !!url && JSON5_EXTENSION.test(url);
|
||||
|
||||
const looksLikeJson5ContentType = (contentType: string): boolean => !!contentType && JSON5_MIME.test(contentType);
|
||||
|
||||
const formatParseError = (sourceUrl: string, strictError: unknown, json5Error: unknown): string =>
|
||||
{
|
||||
const strictMessage = (strictError as Error)?.message || String(strictError);
|
||||
const json5Message = (json5Error as Error)?.message || String(json5Error);
|
||||
const source = sourceUrl ? ` in "${ sourceUrl }"` : '';
|
||||
|
||||
if(strictMessage === json5Message) return `Failed to parse JSON/JSON5${ source } — ${ json5Message }`;
|
||||
|
||||
return `Failed to parse JSON/JSON5${ source } — JSON5: ${ json5Message } (strict JSON: ${ strictMessage })`;
|
||||
};
|
||||
|
||||
const formatStrictError = (sourceUrl: string, err: unknown): string =>
|
||||
{
|
||||
const message = (err as Error)?.message || String(err);
|
||||
const source = sourceUrl ? ` in "${ sourceUrl }"` : '';
|
||||
|
||||
return `Failed to parse strict JSON${ source } — ${ message } (build is in 'legacy' mode; switch to JSON5 mode via 'yarn configure' to accept comments/trailing commas)`;
|
||||
};
|
||||
|
||||
export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''): T =>
|
||||
{
|
||||
if(text === null || text === undefined) throw new Error(`Empty response${ sourceUrl ? ` for "${ sourceUrl }"` : '' }`);
|
||||
|
||||
const trimmed = text.length > 0 ? text : '';
|
||||
const mode = resolveJsonMode();
|
||||
|
||||
if(mode === 'legacy')
|
||||
{
|
||||
try
|
||||
{
|
||||
return JSON.parse(trimmed) as T;
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
throw new Error(formatStrictError(sourceUrl, err));
|
||||
}
|
||||
}
|
||||
|
||||
if(mode === 'json5' || looksLikeJson5Url(sourceUrl))
|
||||
{
|
||||
try
|
||||
{
|
||||
return JSON5.parse<T>(trimmed);
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
throw new Error(formatParseError(sourceUrl, err, err));
|
||||
}
|
||||
}
|
||||
|
||||
let strictError: unknown;
|
||||
|
||||
try
|
||||
{
|
||||
return JSON.parse(trimmed) as T;
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
strictError = err;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JSON5.parse<T>(trimmed);
|
||||
}
|
||||
catch(json5Error)
|
||||
{
|
||||
throw new Error(formatParseError(sourceUrl, strictError, json5Error));
|
||||
}
|
||||
};
|
||||
|
||||
export const parseConfigJsonFromResponse = async <T = any>(response: Response, sourceUrl: string = ''): Promise<T> =>
|
||||
{
|
||||
const contentType = response.headers?.get?.('content-type') || '';
|
||||
const text = await response.text();
|
||||
const url = sourceUrl || (response as any).url || '';
|
||||
const mode = resolveJsonMode();
|
||||
|
||||
if(mode === 'auto' && looksLikeJson5ContentType(contentType) && !looksLikeJson5Url(url))
|
||||
{
|
||||
try
|
||||
{
|
||||
return JSON5.parse<T>(text);
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
throw new Error(formatParseError(url, err, err));
|
||||
}
|
||||
}
|
||||
|
||||
return parseConfigJson<T>(text, url);
|
||||
};
|
||||
|
||||
export const fetchConfigJson = async <T = any>(url: string, init?: RequestInit): Promise<T> =>
|
||||
{
|
||||
const response = await fetch(url, init);
|
||||
|
||||
if(!response || response.status !== 200) throw new Error(`Failed to fetch "${ url }" — server returned HTTP ${ response?.status ?? 'no response' }`);
|
||||
|
||||
return parseConfigJsonFromResponse<T>(response, url);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fetchConfigJson, parseConfigJson, parseConfigJsonFromResponse } from '../JsonParser';
|
||||
|
||||
describe('parseConfigJson', () =>
|
||||
{
|
||||
it('parses strict JSON', () =>
|
||||
{
|
||||
const result = parseConfigJson('{"a": 1, "b": [2, 3]}');
|
||||
expect(result).toEqual({ a: 1, b: [ 2, 3 ] });
|
||||
});
|
||||
|
||||
it('falls back to JSON5 for trailing commas', () =>
|
||||
{
|
||||
const result = parseConfigJson('{"a": 1, "b": [2, 3,],}');
|
||||
expect(result).toEqual({ a: 1, b: [ 2, 3 ] });
|
||||
});
|
||||
|
||||
it('falls back to JSON5 for comments', () =>
|
||||
{
|
||||
const result = parseConfigJson(`{
|
||||
// a number
|
||||
"a": 1,
|
||||
/* a list */
|
||||
"b": [2, 3]
|
||||
}`);
|
||||
expect(result).toEqual({ a: 1, b: [ 2, 3 ] });
|
||||
});
|
||||
|
||||
it('falls back to JSON5 for unquoted keys and single quotes', () =>
|
||||
{
|
||||
const result = parseConfigJson("{ a: 1, b: 'hello' }");
|
||||
expect(result).toEqual({ a: 1, b: 'hello' });
|
||||
});
|
||||
|
||||
it('uses JSON5 directly for .json5 URLs', () =>
|
||||
{
|
||||
const result = parseConfigJson('{ a: 1, /* hi */ b: 2 }', 'https://example.com/cfg.json5');
|
||||
expect(result).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it('throws a helpful error when both strict and JSON5 fail', () =>
|
||||
{
|
||||
expect(() => parseConfigJson('{ this is :: not json ::', 'cfg.json'))
|
||||
.toThrowError(/Failed to parse JSON\/JSON5 in "cfg\.json"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseConfigJsonFromResponse', () =>
|
||||
{
|
||||
const buildResponse = (body: string, contentType = 'application/json', url = 'https://example.com/x.json'): Response =>
|
||||
{
|
||||
const headers = new Headers({ 'content-type': contentType });
|
||||
return new Response(body, { status: 200, headers });
|
||||
};
|
||||
|
||||
it('parses JSON response bodies', async () =>
|
||||
{
|
||||
const res = buildResponse('{"a": 1}');
|
||||
await expect(parseConfigJsonFromResponse(res, 'https://example.com/x.json')).resolves.toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('parses JSON5 response bodies with comments', async () =>
|
||||
{
|
||||
const res = buildResponse('{ /* yo */ a: 1, b: 2, }');
|
||||
await expect(parseConfigJsonFromResponse(res, 'https://example.com/x.json')).resolves.toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it('respects application/json5 content-type', async () =>
|
||||
{
|
||||
const res = buildResponse('{ a: 1 }', 'application/json5');
|
||||
await expect(parseConfigJsonFromResponse(res, 'https://example.com/x.txt')).resolves.toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchConfigJson', () =>
|
||||
{
|
||||
it('fetches and parses JSON or JSON5', async () =>
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => new Response('{ a: 1, b: 2, }', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})) as any;
|
||||
|
||||
try
|
||||
{
|
||||
await expect(fetchConfigJson('https://example.com/cfg.json')).resolves.toEqual({ a: 1, b: 2 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('throws for non-200 responses', async () =>
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => new Response('', { status: 404 })) as any;
|
||||
|
||||
try
|
||||
{
|
||||
await expect(fetchConfigJson('https://example.com/missing.json')).rejects.toThrowError(/HTTP 404/);
|
||||
}
|
||||
finally
|
||||
{
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ export * from './BinaryReader';
|
||||
export * from './BinaryWriter';
|
||||
export * from './ColorConverter';
|
||||
export * from './FurniId';
|
||||
export * from './GamedataLoader';
|
||||
export * from './GetPixi';
|
||||
export * from './GetRenderer';
|
||||
export * from './GetStage';
|
||||
@@ -13,6 +14,7 @@ export * from './GetTickerFPS';
|
||||
export * from './GetTickerTime';
|
||||
export * from './HabboWebTools';
|
||||
export * from './Int32';
|
||||
export * from './JsonParser';
|
||||
export * from './LegacyExternalInterface';
|
||||
export * from './LinkTracker';
|
||||
export * from './Matrix4x4';
|
||||
|
||||
Reference in New Issue
Block a user