Merge branch 'Dev' into merge-duckie-main-2026-05-06

This commit is contained in:
DuckieTM
2026-05-25 18:48:34 +02:00
committed by GitHub
77 changed files with 1559 additions and 201 deletions
+1 -1
View File
@@ -15,6 +15,6 @@
"pixi.js": "^8.8.1"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
@@ -7,4 +7,5 @@ export interface IEventDispatcher
removeEventListener(type: string, callback: Function): void;
removeAllListeners(): void;
dispatchEvent<T extends INitroEvent>(event: T): boolean;
subscribe<T extends INitroEvent>(type: string | string[], callback: (event: T) => void): () => void;
}
@@ -6,5 +6,6 @@ export interface ICommunicationManager
init(): Promise<void>;
registerMessageEvent(event: IMessageEvent): IMessageEvent;
removeMessageEvent(event: IMessageEvent): void;
subscribeMessage<T extends IMessageEvent>(eventCtor: new (callback: (event: T) => void) => T, handler: (event: T) => void): () => void;
connection: IConnection;
}
@@ -2,4 +2,13 @@ export interface IGroupInformationManager
{
init(): void;
getGroupBadge(groupId: number): string;
/**
* Returns the current `groupId -> badgeId` map as a frozen,
* referentially stable ReadonlyMap. The same reference is returned
* across reads until the underlying badges change; mutations
* dispatch `NitroEventType.GROUP_BADGES_UPDATED` to signal
* invalidation.
*/
getGroupBadgesSnapshot(): ReadonlyMap<number, string>;
}
@@ -6,4 +6,14 @@ export interface IIgnoredUsersManager
ignoreUser(name: string): void;
unignoreUser(name: string): void;
isIgnored(name: string): boolean;
/**
* Returns the current ignored-users list as a frozen, referentially
* stable array. The same reference is returned across reads until
* the list is mutated; mutations dispatch
* `NitroEventType.IGNORED_USERS_UPDATED` to signal invalidation.
*
* Pairs with `useSyncExternalStore` on the React client.
*/
getIgnoredUsersSnapshot(): ReadonlyArray<string>;
}
@@ -9,10 +9,11 @@ export interface IRoomSession
setRoomOwner(): void;
start(): boolean;
reset(roomId: number): void;
sendChatMessage(text: string, styleId: number, chatColour: string): void;
sendShoutMessage(text: string, styleId: number, chatColour: string): void;
sendChatMessage(text: string, styleId: number, chatColour?: string): void;
sendShoutMessage(text: string, styleId: number, chatColour?: string): void;
sendWhisperMessage(recipientName: string, text: string, styleId: number): void;
sendChatTypingMessage(isTyping: boolean): void;
sendBackgroundMessage(backgroundImage: number, backgroundStand: number, backgroundOverlay: number, backgroundCard?: number, backgroundBorder?: number): void;
sendMottoMessage(motto: string): void;
sendDanceMessage(danceId: number): void;
sendExpressionMessage(expression: number): void;
@@ -20,7 +21,6 @@ export interface IRoomSession
sendPostureMessage(posture: number): void;
sendDoorbellApprovalMessage(userName: string, flag: boolean): void;
sendAmbassadorAlertMessage(userId: number): void;
sendWhisperGroupMessage(userId: number): void;
sendKickMessage(userId: number): void;
sendMuteMessage(userId: number, minutes: number): void;
sendBanMessage(userId: number, type: string): void;
@@ -50,6 +50,7 @@ export interface IRoomSession
sendScriptProceed(): void;
userDataManager: IUserDataManager;
roomId: number;
password: string;
state: string;
tradeMode: number;
isPrivateRoom: boolean;
@@ -1,4 +1,5 @@
import { IRoomSession } from './IRoomSession';
import { IRoomSessionSnapshot } from './IRoomSessionSnapshot';
export interface IRoomSessionManager
{
@@ -8,5 +9,6 @@ export interface IRoomSessionManager
startSession(session: IRoomSession): boolean;
removeSession(id: number, openLandingView?: boolean): void;
tryRestoreSession(): boolean;
getActiveRoomSessionSnapshot(): Readonly<IRoomSessionSnapshot> | null;
viewerSession: IRoomSession;
}
@@ -0,0 +1,18 @@
import { IRoomSession } from './IRoomSession';
export interface IRoomSessionSnapshot
{
roomId: number;
state: string;
isRoomOwner: boolean;
isSpectator: boolean;
isDecorating: boolean;
isGuildRoom: boolean;
isPrivateRoom: boolean;
controllerLevel: number;
doorMode: number;
tradeMode: number;
allowPets: boolean;
groupId: number;
session: IRoomSession;
}
@@ -6,6 +6,7 @@ export interface IRoomUserData
stand: number;
overlay: number;
cardBackground: number;
borderId: number;
name: string;
type: number;
sex: string;
@@ -3,6 +3,7 @@ import { IFurnitureData } from './IFurnitureData';
import { IGroupInformationManager } from './IGroupInformationManager';
import { IIgnoredUsersManager } from './IIgnoredUsersManager';
import { IProductData } from './IProductData';
import { IUserDataSnapshot } from './IUserDataSnapshot';
export interface ISessionDataManager
{
@@ -53,4 +54,12 @@ export interface ISessionDataManager
isCameraFollowDisabled: boolean;
uiFlags: number;
tags: string[];
getUserDataSnapshot(): Readonly<IUserDataSnapshot>;
/**
* Referentially-stable view of the resolved permission map for
* the current user. Invalidated by `USER_PERMISSIONS_UPDATED`.
* Empty when the connected emulator doesn't ship the extended
* `UserPermissionsMapComposer` (Arcturus ≥ 4.2.10).
*/
getPermissionsSnapshot(): ReadonlyMap<string, number>;
}
@@ -18,9 +18,25 @@ export interface IUserDataManager
updateMotto(roomIndex: number, custom: string): void;
updateNickIcon(roomIndex: number, nickIcon: string): void;
updateCustomization(roomIndex: number, nickIcon: string, prefixText: string, prefixColor: string, prefixIcon: string, prefixEffect: string, prefixFont: string, displayOrder: string): void;
updateBackground(roomIndex: number, background: number, stand: number, overlay: number, cardBackground?: number): void;
updateBackground(roomIndex: number, background: number, stand: number, overlay: number, cardBackground?: number, borderId?: number): void;
updateAchievementScore(roomIndex: number, score: number): void;
updatePetLevel(roomIndex: number, level: number): void;
updatePetBreedingStatus(roomIndex: number, canBreed: boolean, canHarvest: boolean, canRevive: boolean, hasBreedingPermission: boolean): void;
requestPetInfo(id: number): void;
/**
* Returns the current room's user list as a referentially-stable
* ReadonlyArray. The same array reference is returned across reads
* until any user is added, removed, or has a tracked field updated
* (figure / name / motto / nick icon / customization / background /
* achievement score / pet level / breeding status). Mutations
* dispatch `NitroEventType.ROOM_USER_LIST_UPDATED` to signal
* invalidation.
*
* The inner IRoomUserData objects keep the existing in-place
* mutation semantics — they are NOT deep-cloned. Treat them as
* snapshots-at-time-of-read; consumers should not retain individual
* entries across invalidations.
*/
getRoomUserListSnapshot(): ReadonlyArray<IRoomUserData>;
}
@@ -0,0 +1,31 @@
export interface IUserDataSnapshot
{
userId: number;
userName: string;
figure: string;
gender: string;
realName: string;
respectsReceived: number;
respectsLeft: number;
respectsPetLeft: number;
canChangeName: boolean;
clubLevel: number;
securityLevel: number;
isAmbassador: boolean;
isEmailVerified: boolean;
isNoob: boolean;
isAuthenticHabbo: boolean;
isSystemOpen: boolean;
isSystemShutdown: boolean;
uiFlags: number;
tags: ReadonlyArray<string>;
// Rank metadata mirrored from `permission_ranks` (Arcturus emulator
// ≥ 4.2.10 ships these via `UserPermissionsComposer`). Older
// emulators leave them at the defaults (rankId=0, empty strings)
// because the renderer-side parser short-circuits on bytesAvailable.
rankId: number;
rankName: string;
rankBadge: string;
rankPrefix: string;
rankPrefixColor: string;
}
+2
View File
@@ -18,6 +18,8 @@ export * from './IRoomSessionManager';
export * from './IRoomUserData';
export * from './ISessionDataManager';
export * from './IUserDataManager';
export * from './IUserDataSnapshot';
export * from './IRoomSessionSnapshot';
export * from './PetBreedingResultData';
export * from './PetCustomPart';
export * from './PetFigureData';
@@ -1,8 +1,22 @@
import { IMusicController } from './IMusicController';
import { ISoundVolumesSnapshot } from './ISoundVolumesSnapshot';
export interface ISoundManager
{
init(): Promise<void>;
musicController: IMusicController;
traxVolume: number;
systemVolume: number;
furniVolume: number;
/**
* Returns a referentially-stable snapshot of the three volume
* levels (system / furni / trax). The same reference is returned
* across reads until a volume changes; mutations dispatch
* `NitroEventType.SOUND_VOLUMES_UPDATED` to signal invalidation.
*
* Pairs with `useSyncExternalStore` on the React client for
* volume-slider widgets.
*/
getVolumesSnapshot(): Readonly<ISoundVolumesSnapshot>;
}
@@ -0,0 +1,6 @@
export interface ISoundVolumesSnapshot
{
system: number;
furni: number;
trax: number;
}
+1
View File
@@ -2,3 +2,4 @@ export * from './IMusicController';
export * from './IPlaylistController';
export * from './ISongInfo';
export * from './ISoundManager';
export * from './ISoundVolumesSnapshot';
+3 -3
View File
@@ -14,10 +14,10 @@
"dependencies": {
"@nitrots/api": "1.0.0",
"@nitrots/utils": "1.0.0",
"@pixi/gif": "^3.0.1",
"pixi.js": "^8.8.1"
"@pixi/gif": "^3.0.1",
"pixi.js": "^8.8.1"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
+2 -2
View File
@@ -227,7 +227,7 @@ export class AssetManager implements IAssetManager
for(const path in merged)
{
const mod = merged[path];
const imageUrl = (mod.default ?? mod) as string;
const imageUrl = ((mod as { default?: string }).default ?? mod) as string;
const file = path.split('/').pop()!;
const rawName = file.replace(/\.png$/i, '');
@@ -296,7 +296,7 @@ export class AssetManager implements IAssetManager
if(!path.startsWith(prefix)) continue;
const mod = allImages[path];
const imageUrl = (mod.default ?? mod) as string;
const imageUrl = ((mod as { default?: string }).default ?? mod) as string;
const file = path.split('/').pop()!;
const rawName = file.replace(/\.png$/i, '');
+1 -1
View File
@@ -15,6 +15,6 @@
"@nitrots/utils": "1.0.0"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
+5 -8
View File
@@ -1,6 +1,6 @@
import { AvatarAction, AvatarDirectionAngle, AvatarScaleType, AvatarSetType, IActiveActionData, IAnimationLayerData, IAvatarDataContainer, IAvatarEffectListener, IAvatarFigureContainer, IAvatarImage, IPartColor, ISpriteDataContainer } from '@nitrots/api';
import { AvatarAction, AvatarDirectionAngle, AvatarScaleType, AvatarSetType, IActiveActionData, IAnimationLayerData, IAvatarDataContainer, IAvatarEffectListener, IAvatarFigureContainer, IAvatarImage, IGraphicAsset, IPartColor, ISpriteDataContainer } from '@nitrots/api';
import { GetRenderer, GetTexturePool, GetTickerTime, PaletteMapFilter, TextureUtils } from '@nitrots/utils';
import { ColorMatrixFilter, Container, RenderTexture, Sprite, Texture } from 'pixi.js';
import { ColorMatrixFilter, Container, Filter, RenderTexture, Sprite, Texture } from 'pixi.js';
import { AvatarFigureContainer } from './AvatarFigureContainer';
import { AvatarStructure } from './AvatarStructure';
import { EffectAssetDownloadManager } from './EffectAssetDownloadManager';
@@ -243,8 +243,7 @@ export class AvatarImage implements IAvatarImage, IAvatarEffectListener
if(this._avatarSpriteData.colorTransform)
{
if(container.filters === undefined || container.filters === null) container.filters = [ this._avatarSpriteData.colorTransform ];
else if(Array.isArray(container.filters)) container.filters = [ ...container.filters, this._avatarSpriteData.colorTransform ];
else container.filters = [ container.filters, this._avatarSpriteData.colorTransform ];
else container.filters = [ ...(container.filters as readonly Filter[]), this._avatarSpriteData.colorTransform ];
}
if(this._avatarSpriteData.paletteIsGrayscale)
@@ -257,8 +256,7 @@ export class AvatarImage implements IAvatarImage, IAvatarEffectListener
});
if(container.filters === undefined || container.filters === null) container.filters = [ paletteMapFilter ];
else if(Array.isArray(container.filters)) container.filters = [ ...container.filters, paletteMapFilter ];
else container.filters = [ container.filters, paletteMapFilter ];
else container.filters = [ ...(container.filters as readonly Filter[]), paletteMapFilter ];
}
}
@@ -766,8 +764,7 @@ export class AvatarImage implements IAvatarImage, IAvatarEffectListener
];
if(container.filters === undefined || container.filters === null) container.filters = [ filter ];
else if(Array.isArray(container.filters)) container.filters = [ ...container.filters, filter ];
else container.filters = [ container.filters, filter ];
else container.filters = [ ...(container.filters as readonly Filter[]), filter ];
return container;
}
+1 -1
View File
@@ -17,6 +17,6 @@
"pixi.js": "^8.8.1"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
+2 -2
View File
@@ -12,9 +12,9 @@
"@nitrots/api": "1.0.0",
"@nitrots/events": "1.0.0",
"@nitrots/utils": "1.0.0",
"@thumbmarkjs/thumbmarkjs": "^1.8.1"
"@thumbmarkjs/thumbmarkjs": "^1.9.0"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
@@ -203,6 +203,17 @@ export class CommunicationManager implements ICommunicationManager
this._connection.removeMessageEvent(event);
}
public subscribeMessage<T extends IMessageEvent>(eventCtor: new (callback: (event: T) => void) => T, handler: (event: T) => void): () => void
{
if(!eventCtor || !handler) return () => {};
const event = new eventCtor(handler);
this.registerMessageEvent(event);
return () => this.removeMessageEvent(event);
}
public get connection(): IConnection
{
return this._connection;
@@ -1,4 +1,4 @@
import { ICodec, IConnection, IMessageComposer, IMessageConfiguration, IMessageDataWrapper, IMessageEvent, WebSocketEventEnum } from '@nitrots/api';
import { ICodec, IConnection, IMessageComposer, IMessageConfiguration, IMessageDataWrapper, IMessageEvent, IMessageParser, WebSocketEventEnum } from '@nitrots/api';
import { GetConfiguration } from '@nitrots/configuration';
import { GetEventDispatcher, NitroEvent, NitroEventType, ReconnectEvent } from '@nitrots/events';
import { NitroLogger } from '@nitrots/utils';
@@ -509,7 +509,7 @@ export class SocketConnection implements IConnection
try
{
const parser = new events[0].parserClass();
const parser = new (events[0].parserClass as new () => IMessageParser)();
if(!parser || !parser.flush() || !parser.parse(wrapper)) return null;
@@ -38,17 +38,17 @@ export async function deriveAesKey(sharedSecret: ArrayBuffer): Promise<CryptoKey
);
}
export async function aesGcmEncrypt(key: CryptoKey, nonce: Uint8Array, plaintext: ArrayBuffer): Promise<ArrayBuffer>
export async function aesGcmEncrypt(key: CryptoKey, nonce: Uint8Array<ArrayBuffer>, plaintext: ArrayBuffer): Promise<ArrayBuffer>
{
return window.crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, tagLength: GCM_TAG_LEN * 8 }, key, plaintext);
}
export async function aesGcmDecrypt(key: CryptoKey, nonce: Uint8Array, ciphertextWithTag: ArrayBuffer): Promise<ArrayBuffer>
export async function aesGcmDecrypt(key: CryptoKey, nonce: Uint8Array<ArrayBuffer>, ciphertextWithTag: ArrayBuffer): Promise<ArrayBuffer>
{
return window.crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, tagLength: GCM_TAG_LEN * 8 }, key, ciphertextWithTag);
}
export function randomNonce(): Uint8Array
export function randomNonce(): Uint8Array<ArrayBuffer>
{
const n = new Uint8Array(NONCE_LEN);
window.crypto.getRandomValues(n);
@@ -4,9 +4,9 @@ export class CatalogAdminSavePageComposer implements IMessageComposer<Constructo
{
private _data: ConstructorParameters<typeof CatalogAdminSavePageComposer>;
constructor(pageId: number, caption: string, caption2: string, layout: string, iconType: number, minRank: number, visible: boolean, enabled: boolean, orderNum: number, parentId: number, headline: string, teaser: string, textDetails: string, targetCatalogType: string, catalogMode: string = 'NORMAL')
constructor(pageId: number, caption: string, caption2: string, layout: string, iconType: number, minRank: number, visible: boolean, enabled: boolean, orderNum: number, parentId: number, headline: string, teaser: string, textDetails: string, targetCatalogType: string, catalogMode: string = 'NORMAL', pageText1: string = '')
{
this._data = [ pageId, caption, caption2, layout, iconType, minRank, visible, enabled, orderNum, parentId, headline, teaser, textDetails, targetCatalogType, catalogMode ];
this._data = [ pageId, caption, caption2, layout, iconType, minRank, visible, enabled, orderNum, parentId, headline, teaser, textDetails, targetCatalogType, catalogMode, pageText1 ];
}
dispose(): void
@@ -1,12 +1,23 @@
import { IMessageComposer } from '@nitrots/api';
export class RoomEnterComposer implements IMessageComposer<ConstructorParameters<typeof RoomEnterComposer>>
{
private _data: ConstructorParameters<typeof RoomEnterComposer>;
type RoomEnterPayload = [ number, string, number?, number? ];
constructor(roomId: number, password: string = null)
export class RoomEnterComposer implements IMessageComposer<RoomEnterPayload>
{
private _data: RoomEnterPayload;
/**
* Optional spawnX/spawnY let the server resume the avatar at a
* specific tile when re-entering the same room — used by the
* reconnect flow. Arcturus' RequestRoomLoadEvent reads both ints
* only if `packet.remaining >= 8`, so omitting them keeps the
* legacy enter-via-door behavior.
*/
constructor(roomId: number, password: string = null, spawnX?: number, spawnY?: number)
{
this._data = [roomId, password];
this._data = (spawnX !== undefined && spawnY !== undefined)
? [ roomId, password, spawnX, spawnY ]
: [ roomId, password ];
}
public getMessageArray()
@@ -32,7 +32,8 @@ implements
chatBubbleWeight: number,
chatBubbleSpeed: number,
chatDistance: number,
chatFloodProtection: number
chatFloodProtection: number,
allowUnderpass?: boolean
)
{
//@ts-ignore
@@ -67,6 +68,8 @@ implements
chatDistance,
chatFloodProtection
);
if(allowUnderpass !== undefined) this._data.push(allowUnderpass);
}
public getMessageArray()
@@ -4,9 +4,9 @@ export class RoomUnitBackgroundComposer implements IMessageComposer<ConstructorP
{
private _data: ConstructorParameters<typeof RoomUnitBackgroundComposer>;
constructor(backgroundImage: number, backgroundStand: number, backgroundOverlay: number, backgroundCard: number = 0)
constructor(backgroundImage: number, backgroundStand: number, backgroundOverlay: number, backgroundCard: number = 0, backgroundBorder: number = 0)
{
this._data = [ backgroundImage, backgroundStand, backgroundOverlay, backgroundCard ];
this._data = [ backgroundImage, backgroundStand, backgroundOverlay, backgroundCard, backgroundBorder ];
}
public getMessageArray()
@@ -1,8 +1,8 @@
import { IMessageComposer } from '@nitrots/api';
export class WiredRoomSettingsRequestComposer implements IMessageComposer<ConstructorParameters<typeof WiredRoomSettingsRequestComposer>>
export class WiredRoomSettingsRequestComposer implements IMessageComposer<[]>
{
public getMessageArray()
public getMessageArray(): []
{
return [];
}
@@ -1,8 +1,8 @@
import { IMessageComposer } from '@nitrots/api';
export class WiredUserVariablesRequestComposer implements IMessageComposer<ConstructorParameters<typeof WiredUserVariablesRequestComposer>>
export class WiredUserVariablesRequestComposer implements IMessageComposer<[]>
{
public getMessageArray()
public getMessageArray(): []
{
return [];
}
@@ -5,6 +5,7 @@ export class NodeData
private _visible: boolean;
private _icon: number;
private _pageId: number;
private _parentId: number;
private _pageName: string;
private _localization: string;
private _children: NodeData[];
@@ -23,6 +24,7 @@ export class NodeData
this._visible = false;
this._icon = 0;
this._pageId = -1;
this._parentId = -1;
this._pageName = null;
this._localization = null;
this._children = [];
@@ -43,6 +45,7 @@ export class NodeData
this._visible = wrapper.readBoolean();
this._icon = wrapper.readInt();
this._pageId = wrapper.readInt();
this._parentId = wrapper.readInt();
this._pageName = wrapper.readString();
this._localization = wrapper.readString();
@@ -92,6 +95,11 @@ export class NodeData
return this._pageId;
}
public get parentId(): number
{
return this._parentId;
}
public get pageName(): string
{
return this._pageName;
@@ -19,17 +19,16 @@ export class PetBreedingMessageParser implements IMessageParser
return true;
}
public parse(wrapper: IMessageDataWrapper): boolean {
if (!wrapper || wrapper.bytesAvailable < 12) {
return false;
}
public parse(wrapper: IMessageDataWrapper): boolean
{
if(!wrapper || !wrapper.bytesAvailable) return false;
this._state = wrapper.readInt();
this._ownPetId = wrapper.readInt();
this._otherPetId = wrapper.readInt();
this._state = wrapper.readInt();
this._ownPetId = wrapper.readInt();
this._otherPetId = wrapper.readInt();
return true;
}
return true;
}
public get state(): number
{
@@ -45,12 +45,19 @@ export class GetGuestRoomResultMessageParser implements IMessageParser
this.data.canMute = wrapper.readBoolean();
this._chat = new RoomChatSettings(wrapper);
if(wrapper.bytesAvailable)
{
this._hotelTimeZoneId = wrapper.readString();
this._hotelCurrentTimeMs = Number(wrapper.readString()) || 0;
if(wrapper.bytesAvailable) this._roomItemLimit = wrapper.readInt();
}
// Optional trailing blocks, one tier per emulator release:
// block 1: hotel timezone id + current time ms (2 strings)
// block 2: room item limit (1 int)
// Flat early-return chain so an older server stops cleanly at
// whichever block it doesn't ship. Defaults from flush().
if(!wrapper.bytesAvailable) return true;
this._hotelTimeZoneId = wrapper.readString();
this._hotelCurrentTimeMs = Number(wrapper.readString()) || 0;
if(!wrapper.bytesAvailable) return true;
this._roomItemLimit = wrapper.readInt();
return true;
}
@@ -11,6 +11,7 @@ export class RoomUnitInfoParser implements IMessageParser
private _standId: number;
private _overlayId: number;
private _cardBackgroundId: number;
private _borderId: number;
private _nickIcon: string;
private _prefixText: string;
private _prefixColor: string;
@@ -30,6 +31,7 @@ export class RoomUnitInfoParser implements IMessageParser
this._standId = 0;
this._overlayId = 0;
this._cardBackgroundId = 0;
this._borderId = 0;
this._nickIcon = '';
this._prefixText = '';
this._prefixColor = '';
@@ -61,6 +63,7 @@ export class RoomUnitInfoParser implements IMessageParser
this._prefixEffect = (wrapper.bytesAvailable ? wrapper.readString() : '');
this._prefixFont = (wrapper.bytesAvailable ? wrapper.readString() : '');
this._displayOrder = (wrapper.bytesAvailable ? wrapper.readString() : 'icon-prefix-name');
this._borderId = (wrapper.bytesAvailable ? wrapper.readInt() : 0);
return true;
}
@@ -110,6 +113,11 @@ export class RoomUnitInfoParser implements IMessageParser
return this._cardBackgroundId;
}
public get borderId(): number
{
return this._borderId;
}
public get nickIcon(): string
{
return this._nickIcon;
@@ -146,6 +146,17 @@ export class RoomUnitParser implements IMessageParser
user.roomEntryMethod = wrapper.readString();
user.roomEntryTeleportId = wrapper.readInt();
// Arcturus appends a trailing borderId int per user
// (RoomUsersComposer, after the Infostand Borders feature)
// for every record — habbo, bot, rentable bot — using 0 as
// the constant for the records that have no border. The
// read MUST be unconditional: a bytesAvailable guard would
// be semantically wrong here (the guard answers "any byte
// left in the whole packet?" not "any byte left for THIS
// user"), and skipping the read would leave 4 bytes per
// record and cascade-corrupt every subsequent user in the
// roster.
user.borderId = wrapper.readInt();
i++;
}
@@ -45,6 +45,7 @@ export class UserMessageData
private _isModerator: boolean = false;
private _roomEntryMethod: string = 'unknown';
private _roomEntryTeleportId: number = 0;
private _borderId: number = 0;
private _isReadOnly: boolean = false;
constructor(k: number)
@@ -579,4 +580,17 @@ export class UserMessageData
this._roomEntryTeleportId = k;
}
}
public get borderId(): number
{
return this._borderId;
}
public set borderId(k: number)
{
if(!this._isReadOnly)
{
this._borderId = k;
}
}
}
@@ -37,6 +37,7 @@ export class RoomSettingsData
private _roomModerationSettings: RoomModerationSettings = null;
private _chatSettings: RoomChatSettings = null;
private _allowNavigatorDynamicCats: boolean = false;
private _allowUnderpass: boolean = false;
public static from(settings: RoomSettingsData)
{
@@ -65,6 +66,7 @@ export class RoomSettingsData
instance._roomModerationSettings = settings._roomModerationSettings;
instance._chatSettings = settings._chatSettings;
instance._allowNavigatorDynamicCats = settings._allowNavigatorDynamicCats;
instance._allowUnderpass = settings._allowUnderpass;
return instance;
}
@@ -329,4 +331,14 @@ export class RoomSettingsData
{
this._allowNavigatorDynamicCats = flag;
}
public get allowUnderpass(): boolean
{
return this._allowUnderpass;
}
public set allowUnderpass(flag: boolean)
{
this._allowUnderpass = flag;
}
}
@@ -49,6 +49,10 @@ export class RoomSettingsDataParser implements IMessageParser
this._roomSettingsData.allowNavigatorDynamicCats = wrapper.readBoolean();
this._roomSettingsData.roomModerationSettings = new RoomModerationSettings(wrapper);
// Custom Arcturus extension: trailing int (0/1) for the underpass toggle.
// Older servers may not emit it; default stays false when absent.
if(wrapper.bytesAvailable) this._roomSettingsData.allowUnderpass = (wrapper.readInt() === 1);
return true;
}
@@ -5,12 +5,24 @@ export class UserPermissionsParser implements IMessageParser
private _clubLevel: number;
private _securityLevel: number;
private _isAmbassador: boolean;
private _rankId: number;
private _rankName: string;
private _rankBadge: string;
private _rankPrefix: string;
private _rankPrefixColor: string;
private _permissions: Map<string, number> = new Map();
public flush(): boolean
{
this._clubLevel = 0;
this._securityLevel = 0;
this._isAmbassador = false;
this._rankId = 0;
this._rankName = '';
this._rankBadge = '';
this._rankPrefix = '';
this._rankPrefixColor = '';
this._permissions = new Map();
return true;
}
@@ -23,6 +35,37 @@ export class UserPermissionsParser implements IMessageParser
this._securityLevel = wrapper.readInt();
this._isAmbassador = wrapper.readBoolean();
// Optional trailing block (Arcturus-Morningstar-Extended ≥ 4.2.10):
// rank metadata + resolved permission map appended in a
// backward-compatible way. Older emulators don't write these
// bytes so we keep the defaults from flush().
if(!wrapper.bytesAvailable) return true;
this._rankId = wrapper.readInt();
this._rankName = wrapper.readString();
this._rankBadge = wrapper.readString();
this._rankPrefix = wrapper.readString();
this._rankPrefixColor = wrapper.readString();
if(!wrapper.bytesAvailable) return true;
// Resolved permission map: int count + (string key, int value)*.
// value 1 = ALLOWED, 2 = ROOM_OWNER. Only entries with
// PermissionSetting != DISALLOWED are sent; absence on the client
// means "no" (useHasPermission(key) returns false).
const count = wrapper.readInt();
const permissions = new Map<string, number>();
for(let i = 0; i < count; i++)
{
const key = wrapper.readString();
const value = wrapper.readInt();
permissions.set(key, value);
}
this._permissions = permissions;
return true;
}
@@ -40,4 +83,34 @@ export class UserPermissionsParser implements IMessageParser
{
return this._isAmbassador;
}
public get rankId(): number
{
return this._rankId;
}
public get rankName(): string
{
return this._rankName;
}
public get rankBadge(): string
{
return this._rankBadge;
}
public get rankPrefix(): string
{
return this._rankPrefix;
}
public get rankPrefixColor(): string
{
return this._rankPrefixColor;
}
public get permissions(): ReadonlyMap<string, number>
{
return this._permissions;
}
}
@@ -84,34 +84,35 @@ export class UserProfileParser implements IMessageParser
this._secondsSinceLastVisit = wrapper.readInt();
this._openProfileWindow = wrapper.readBoolean();
if(wrapper.bytesAvailable)
{
this._backgroundId = wrapper.readInt();
this._standId = wrapper.readInt();
this._overlayId = wrapper.readInt();
// Optional trailing blocks, one tier per emulator release:
// block 1: background / stand / overlay (3 ints)
// block 2: card background (1 int)
// block 3: nick icon (1 string)
// block 4: prefix decoration set (6 strings)
// Each tier early-returns to keep the parser tolerant of older
// servers that don't ship the later blocks. Defaults set by flush().
if(!wrapper.bytesAvailable) return true;
this._cardBackgroundId = (wrapper.bytesAvailable ? wrapper.readInt() : 0);
this._backgroundId = wrapper.readInt();
this._standId = wrapper.readInt();
this._overlayId = wrapper.readInt();
if(wrapper.bytesAvailable)
{
this._nickIcon = wrapper.readString();
if(!wrapper.bytesAvailable) return true;
if(wrapper.bytesAvailable)
{
this._prefixText = wrapper.readString();
this._prefixColor = wrapper.readString();
this._prefixIcon = wrapper.readString();
this._prefixEffect = wrapper.readString();
this._prefixFont = wrapper.readString();
this._displayOrder = wrapper.readString();
this._cardBackgroundId = wrapper.readInt();
if(wrapper.bytesAvailable)
{
this._totalBadges = wrapper.readInt();
}
}
}
}
if(!wrapper.bytesAvailable) return true;
this._nickIcon = wrapper.readString();
if(!wrapper.bytesAvailable) return true;
this._prefixText = wrapper.readString();
this._prefixColor = wrapper.readString();
this._prefixIcon = wrapper.readString();
this._prefixEffect = wrapper.readString();
this._prefixFont = wrapper.readString();
this._displayOrder = wrapper.readString();
return true;
}
+1 -1
View File
@@ -13,6 +13,6 @@
"@nitrots/utils": "1.0.0"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -13,6 +13,6 @@
"@nitrots/utils": "1.0.0"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
+19
View File
@@ -101,4 +101,23 @@ export class EventDispatcher implements IEventDispatcher
{
this._listeners.clear();
}
public subscribe<T extends INitroEvent>(type: string | string[], callback: (event: T) => void): () => void
{
if(!type || !callback) return () => {};
if(Array.isArray(type))
{
for(const t of type) this.addEventListener<T>(t, callback);
return () =>
{
for(const t of type) this.removeEventListener(t, callback);
};
}
this.addEventListener<T>(type, callback);
return () => this.removeEventListener(type, callback);
}
}
+7
View File
@@ -17,4 +17,11 @@ export class NitroEventType
public static readonly AVATAR_EFFECT_DOWNLOADED = 'AVATAR_EFFECT_DOWNLOADED';
public static readonly AVATAR_EFFECT_LOADED = 'AVATAR_EFFECT_LOADED';
public static readonly FURNITURE_DATA_LOADED = 'FURNITURE_DATA_LOADED';
public static readonly SESSION_DATA_UPDATED = 'SESSION_DATA_UPDATED';
public static readonly ROOM_SESSION_UPDATED = 'ROOM_SESSION_UPDATED';
public static readonly IGNORED_USERS_UPDATED = 'IGNORED_USERS_UPDATED';
public static readonly GROUP_BADGES_UPDATED = 'GROUP_BADGES_UPDATED';
public static readonly ROOM_USER_LIST_UPDATED = 'ROOM_USER_LIST_UPDATED';
public static readonly SOUND_VOLUMES_UPDATED = 'SOUND_VOLUMES_UPDATED';
public static readonly USER_PERMISSIONS_UPDATED = 'USER_PERMISSIONS_UPDATED';
}
@@ -20,6 +20,7 @@ export class RoomSessionUserFigureUpdateEvent extends RoomSessionEvent {
private _prefixEffect: string;
private _prefixFont: string;
private _displayOrder: string;
private _borderId: number | null;
constructor(
session: IRoomSession,
@@ -38,7 +39,8 @@ export class RoomSessionUserFigureUpdateEvent extends RoomSessionEvent {
prefixIcon: string = '',
prefixEffect: string = '',
prefixFont: string = '',
displayOrder: string = 'icon-prefix-name'
displayOrder: string = 'icon-prefix-name',
borderId: number | null = 0
) {
super(RoomSessionUserFigureUpdateEvent.USER_FIGURE, session);
@@ -58,6 +60,7 @@ export class RoomSessionUserFigureUpdateEvent extends RoomSessionEvent {
this._prefixEffect = prefixEffect;
this._prefixFont = prefixFont;
this._displayOrder = displayOrder;
this._borderId = borderId;
}
public get roomIndex(): number {
@@ -123,4 +126,8 @@ export class RoomSessionUserFigureUpdateEvent extends RoomSessionEvent {
public get displayOrder(): string {
return this._displayOrder;
}
public get borderId(): number | null {
return this._borderId;
}
}
+1 -1
View File
@@ -17,6 +17,6 @@
"pixi.js": "^8.8.1"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
+1 -1
View File
@@ -19,6 +19,6 @@
"pixi.js": "^8.8.1"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
@@ -170,14 +170,18 @@ export class FurnitureBadgeDisplayVisualization extends FurnitureAnimatedVisuali
return assetName;
}
protected updateSprite(sprite: IRoomObjectSprite, asset: IGraphicAsset, scale: number, layerId: number): void
protected updateSprite(scale: number, layerId: number): void
{
super.updateSprite(sprite, asset, scale, layerId);
super.updateSprite(scale, layerId);
const tag = this.getLayerTag(scale, this.direction, layerId);
if(tag === FurnitureBadgeDisplayVisualization.BADGE_TAG)
{
const sprite = this.getSprite(layerId);
if(!sprite) return;
sprite.visible = true;
sprite.alpha = 255;
sprite.color = 0xFFFFFF;
@@ -1,6 +1,6 @@
import { AlphaTolerance } from '@nitrots/api';
import { GetRenderer, TextureUtils } from '@nitrots/utils';
import { Point, RendererType, Sprite, Texture, TextureSource, WebGPURenderer } from 'pixi.js';
import { GlRenderTarget, Point, RendererType, Sprite, Texture, TextureSource, WebGLRenderer, WebGPURenderer } from 'pixi.js';
const BYTES_PER_PIXEL = 4;
@@ -97,10 +97,11 @@ export class ExtendedSprite extends Sprite
{
pixels = new Uint8ClampedArray(BYTES_PER_PIXEL * width * height);
const renderTarget = renderer.renderTarget.getRenderTarget(textureSource);
const glRenderTarget = renderer.renderTarget.getGpuRenderTarget(renderTarget);
const webglRenderer = renderer as WebGLRenderer;
const renderTarget = webglRenderer.renderTarget.getRenderTarget(textureSource);
const glRenderTarget = webglRenderer.renderTarget.getGpuRenderTarget(renderTarget) as GlRenderTarget;
const gl = renderer.gl;
const gl = webglRenderer.gl;
gl.bindFramebuffer(gl.FRAMEBUFFER, glRenderTarget.resolveTargetFramebuffer);
+1 -1
View File
@@ -19,6 +19,6 @@
"pixi.js": "^8.8.1"
},
"devDependencies": {
"typescript": "~5.8.2"
"typescript": "^6.0.3"
}
}
@@ -1,9 +1,11 @@
import { IGroupInformationManager } from '@nitrots/api';
import { GetCommunication, GetHabboGroupBadgesMessageComposer, HabboGroupBadgesMessageEvent, RoomReadyMessageEvent } from '@nitrots/communication';
import { GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
export class GroupInformationManager implements IGroupInformationManager
{
private _groupBadges: Map<number, string> = new Map();
private _groupBadgesSnapshot: ReadonlyMap<number, string> | null = null;
public init(): void
{
@@ -20,11 +22,37 @@ export class GroupInformationManager implements IGroupInformationManager
{
const parser = event.getParser();
for(const [groupId, badgeId] of parser.badges.entries()) this._groupBadges.set(groupId, badgeId);
let didChange = false;
for(const [ groupId, badgeId ] of parser.badges.entries())
{
if(this._groupBadges.get(groupId) === badgeId) continue;
this._groupBadges.set(groupId, badgeId);
didChange = true;
}
if(didChange) this.invalidateGroupBadgesSnapshot();
}
private invalidateGroupBadgesSnapshot(): void
{
this._groupBadgesSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.GROUP_BADGES_UPDATED));
}
public getGroupBadge(groupId: number): string
{
return this._groupBadges.get(groupId) ?? '';
}
public getGroupBadgesSnapshot(): ReadonlyMap<number, string>
{
if(this._groupBadgesSnapshot) return this._groupBadgesSnapshot;
this._groupBadgesSnapshot = new Map(this._groupBadges) as ReadonlyMap<number, string>;
return this._groupBadgesSnapshot;
}
}
+28 -2
View File
@@ -1,9 +1,27 @@
import { IIgnoredUsersManager } from '@nitrots/api';
import { GetCommunication, GetIgnoredUsersComposer, IgnoreResultEvent, IgnoreUserComposer, IgnoreUserIdComposer, IgnoredUsersEvent, UnignoreUserComposer } from '@nitrots/communication';
import { GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
export class IgnoredUsersManager implements IIgnoredUsersManager
{
private _ignoredUsers: string[] = [];
private _ignoredUsersSnapshot: ReadonlyArray<string> | null = null;
private invalidateIgnoredUsersSnapshot(): void
{
this._ignoredUsersSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.IGNORED_USERS_UPDATED));
}
public getIgnoredUsersSnapshot(): ReadonlyArray<string>
{
if(this._ignoredUsersSnapshot) return this._ignoredUsersSnapshot;
this._ignoredUsersSnapshot = Object.freeze<string[]>([ ...this._ignoredUsers ]) as ReadonlyArray<string>;
return this._ignoredUsersSnapshot;
}
public init(): void
{
@@ -25,6 +43,7 @@ export class IgnoredUsersManager implements IIgnoredUsersManager
if(!parser) return;
this._ignoredUsers = parser.ignoredUsers;
this.invalidateIgnoredUsersSnapshot();
}
private onIgnoreResultEvent(event: IgnoreResultEvent): void
@@ -47,6 +66,7 @@ export class IgnoredUsersManager implements IIgnoredUsersManager
case 2:
this.addUserToIgnoreList(name);
this._ignoredUsers.shift();
this.invalidateIgnoredUsersSnapshot();
return;
case 3:
this.removeUserFromIgnoreList(name);
@@ -56,14 +76,20 @@ export class IgnoredUsersManager implements IIgnoredUsersManager
private addUserToIgnoreList(name: string): void
{
if(this._ignoredUsers.indexOf(name) < 0) this._ignoredUsers.push(name);
if(this._ignoredUsers.indexOf(name) >= 0) return;
this._ignoredUsers.push(name);
this.invalidateIgnoredUsersSnapshot();
}
private removeUserFromIgnoreList(name: string): void
{
const index = this._ignoredUsers.indexOf(name);
if(index >= 0) this._ignoredUsers.splice(index, 1);
if(index < 0) return;
this._ignoredUsers.splice(index, 1);
this.invalidateIgnoredUsersSnapshot();
}
public ignoreUserId(id: number): void
+2 -7
View File
@@ -97,9 +97,9 @@ export class RoomSession implements IRoomSession
else GetCommunication().connection.send(new RoomUnitTypingStopComposer());
}
public sendBackgroundMessage(backgroundImage: number, backgroundStand: number, backgroundOverlay: number, backgroundCard: number = 0): void
public sendBackgroundMessage(backgroundImage: number, backgroundStand: number, backgroundOverlay: number, backgroundCard: number = 0, backgroundBorder: number = 0): void
{
GetCommunication().connection.send(new RoomUnitBackgroundComposer(backgroundImage, backgroundStand, backgroundOverlay, backgroundCard));
GetCommunication().connection.send(new RoomUnitBackgroundComposer(backgroundImage, backgroundStand, backgroundOverlay, backgroundCard, backgroundBorder));
}
public sendMottoMessage(motto: string): void
@@ -138,11 +138,6 @@ export class RoomSession implements IRoomSession
{
GetCommunication().connection.send(new RoomAmbassadorAlertComposer(userId));
}
public sendWhisperGroupMessage(userId: number): void
{
GetCommunication().connection.send(new ChatWhisperGroupComposer(userId));
}
public sendKickMessage(userId: number): void
{
+46 -2
View File
@@ -1,6 +1,6 @@
import { IRoomHandlerListener, IRoomSession, IRoomSessionManager } from '@nitrots/api';
import { IRoomHandlerListener, IRoomSession, IRoomSessionManager, IRoomSessionSnapshot } from '@nitrots/api';
import { GetCommunication, RoomEnterComposer, RoomUnitWalkComposer } from '@nitrots/communication';
import { GetEventDispatcher, NitroEventType, RoomSessionEvent } from '@nitrots/events';
import { GetEventDispatcher, NitroEvent, NitroEventType, RoomSessionEvent } from '@nitrots/events';
import { NitroLogger } from '@nitrots/utils';
import { RoomSession } from './RoomSession';
import { BaseHandler, GenericErrorHandler, PetPackageHandler, PollHandler, RoomChatHandler, RoomDataHandler, RoomDimmerPresetsHandler, RoomPermissionsHandler, RoomPresentHandler, RoomSessionHandler, RoomUsersHandler, WordQuizHandler } from './handler';
@@ -26,6 +26,41 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
private _pendingRoomClear: ReturnType<typeof setTimeout> = null;
private _savedPosX: number = -1;
private _savedPosY: number = -1;
private _activeRoomSessionSnapshot: Readonly<IRoomSessionSnapshot> | null = null;
private invalidateRoomSessionSnapshot(): void
{
this._activeRoomSessionSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.ROOM_SESSION_UPDATED));
}
public getActiveRoomSessionSnapshot(): Readonly<IRoomSessionSnapshot> | null
{
const session = this._viewerSession;
if(!session) return null;
if(this._activeRoomSessionSnapshot && this._activeRoomSessionSnapshot.session === session) return this._activeRoomSessionSnapshot;
this._activeRoomSessionSnapshot = Object.freeze<IRoomSessionSnapshot>({
roomId: session.roomId,
state: session.state,
isRoomOwner: session.isRoomOwner,
isSpectator: session.isSpectator,
isDecorating: session.isDecorating,
isGuildRoom: session.isGuildRoom,
isPrivateRoom: session.isPrivateRoom,
controllerLevel: session.controllerLevel,
doorMode: session.doorMode,
tradeMode: session.tradeMode,
allowPets: session.allowPets,
groupId: session.groupId,
session
});
return this._activeRoomSessionSnapshot;
}
public async init(): Promise<void>
{
@@ -196,6 +231,7 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
this._sessions.clear();
this._viewerSession = null;
this.invalidateRoomSessionSnapshot();
this.createSession(roomId, password, this._savedPosX, this._savedPosY);
this.clearGuardTimer();
this._reconnectGuardTimer = setTimeout(() =>
@@ -384,6 +420,8 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
this._lastRoomPassword = roomSession.password;
this.persistRoom(roomSession.roomId, roomSession.password);
this.invalidateRoomSessionSnapshot();
this.startSession(this._viewerSession);
return true;
@@ -406,6 +444,8 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
this.setHandlers(session);
this.invalidateRoomSessionSnapshot();
return true;
}
@@ -429,6 +469,10 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
}
GetEventDispatcher().dispatchEvent(new RoomSessionEvent(RoomSessionEvent.ENDED, session, openLandingView));
if(this._viewerSession === session) this._viewerSession = null;
this.invalidateRoomSessionSnapshot();
}
public sessionUpdate(id: number, type: string): void
+11
View File
@@ -20,6 +20,7 @@ export class RoomUserData implements IRoomUserData
private _stand: number;
private _overlay: number;
private _cardBackground: number;
private _borderId: number = 0;
private _webID: number = 0;
private _groupID: number = 0;
private _groupStatus: number = 0;
@@ -99,6 +100,16 @@ export class RoomUserData implements IRoomUserData
this._cardBackground = k;
}
public get borderId(): number
{
return this._borderId;
}
public set borderId(k: number)
{
this._borderId = k;
}
public get name(): string
{
return this._name;
+131 -5
View File
@@ -1,8 +1,8 @@
import { IFurnitureData, IGroupInformationManager, IMessageComposer, IMessageEvent, IProductData, ISessionDataManager, NoobnessLevelEnum, SecurityLevel } from '@nitrots/api';
import { IFurnitureData, IGroupInformationManager, IMessageComposer, IMessageEvent, IProductData, ISessionDataManager, IUserDataSnapshot, NoobnessLevelEnum, SecurityLevel } from '@nitrots/api';
import { AccountSafetyLockStatusChangeMessageEvent, AccountSafetyLockStatusChangeParser, AvailabilityStatusMessageEvent, ChangeUserNameResultMessageEvent, EmailStatusResultEvent, FigureUpdateEvent, GetCommunication, GetUserTagsComposer, InClientLinkEvent, MysteryBoxKeysEvent, NoobnessLevelMessageEvent, PetRespectComposer, PetScratchFailedMessageEvent, RoomReadyMessageEvent, RoomUnitChatComposer, UserInfoEvent, UserNameChangeMessageEvent, UserPermissionsEvent, UserRespectComposer, UserTagsMessageEvent } from '@nitrots/communication';
import { GetConfiguration } from '@nitrots/configuration';
import { GetLocalizationManager } from '@nitrots/localization';
import { GetEventDispatcher, MysteryBoxKeysUpdateEvent, NitroSettingsEvent, SessionDataPreferencesEvent, UserNameUpdateEvent } from '@nitrots/events';
import { GetEventDispatcher, MysteryBoxKeysUpdateEvent, NitroEvent, NitroEventType, NitroSettingsEvent, SessionDataPreferencesEvent, UserNameUpdateEvent } from '@nitrots/events';
import { CreateLinkEvent, HabboWebTools, parseConfigJsonFromResponse } from '@nitrots/utils';
import { Texture } from 'pixi.js';
import { GroupInformationManager } from './GroupInformationManager';
@@ -32,6 +32,11 @@ export class SessionDataManager implements ISessionDataManager
private _clubLevel: number = 0;
private _securityLevel: number = 0;
private _isAmbassador: boolean = false;
private _rankId: number = 0;
private _rankName: string = '';
private _rankBadge: string = '';
private _rankPrefix: string = '';
private _rankPrefixColor: string = '';
private _noobnessLevel: number = -1;
private _isEmailVerified: boolean = false;
@@ -52,11 +57,87 @@ export class SessionDataManager implements ISessionDataManager
private _badgeImageManager: BadgeImageManager = new BadgeImageManager();
private _userDataSnapshot: Readonly<IUserDataSnapshot> | null = null;
private _permissions: Map<string, number> = new Map();
private _permissionsSnapshot: ReadonlyMap<string, number> | null = null;
constructor()
{
this.resetUserInfo();
}
private invalidateUserDataSnapshot(): void
{
this._userDataSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SESSION_DATA_UPDATED));
}
private invalidatePermissionsSnapshot(): void
{
this._permissionsSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.USER_PERMISSIONS_UPDATED));
}
/**
* Resolved permission map for the current user — mirror of
* `permission_definitions` for the user's rank, filtered to keys
* with `PermissionSetting != DISALLOWED`. Wire-fed by
* `UserPermissionsMapEvent` (Arcturus ≥ 4.2.10). Older emulators
* that don't ship the new packet leave the snapshot empty; React
* consumers via `useHasPermission(key)` then degrade gracefully
* (every gate returns false → mod UI hidden, which is the safe
* default).
*
* Referentially stable until the next
* `UserPermissionsMapEvent` arrives (e.g. after
* `HabboManager.setRank`).
*/
public getPermissionsSnapshot(): ReadonlyMap<string, number>
{
if(this._permissionsSnapshot) return this._permissionsSnapshot;
this._permissionsSnapshot = new Map(this._permissions) as ReadonlyMap<string, number>;
return this._permissionsSnapshot;
}
public getUserDataSnapshot(): Readonly<IUserDataSnapshot>
{
if(this._userDataSnapshot) return this._userDataSnapshot;
this._userDataSnapshot = Object.freeze<IUserDataSnapshot>({
userId: this._userId,
userName: this._name,
figure: this._figure,
gender: this._gender,
realName: this._realName,
respectsReceived: this._respectsReceived,
respectsLeft: this._respectsLeft,
respectsPetLeft: this._respectsPetLeft,
canChangeName: this._canChangeName,
clubLevel: this._clubLevel,
securityLevel: this._securityLevel,
isAmbassador: this._isAmbassador,
isEmailVerified: this._isEmailVerified,
isNoob: (this._noobnessLevel !== NoobnessLevelEnum.OLD_IDENTITY),
isAuthenticHabbo: this._isAuthenticHabbo,
isSystemOpen: this._systemOpen,
isSystemShutdown: this._systemShutdown,
uiFlags: this._uiFlags,
tags: Object.freeze<string[]>([...this._tags]) as ReadonlyArray<string>,
rankId: this._rankId,
rankName: this._rankName,
rankBadge: this._rankBadge,
rankPrefix: this._rankPrefix,
rankPrefixColor: this._rankPrefixColor
});
return this._userDataSnapshot;
}
public async init(): Promise<void>
{
await Promise.all([
@@ -75,6 +156,8 @@ export class SessionDataManager implements ISessionDataManager
this._gender = event.getParser().gender;
HabboWebTools.updateFigure(this._figure);
this.invalidateUserDataSnapshot();
})),
GetCommunication().registerMessageEvent(new UserInfoEvent(this.onUserInfoEvent.bind(this))),
GetCommunication().registerMessageEvent(new UserPermissionsEvent(this.onUserPermissionsEvent.bind(this))),
@@ -98,6 +181,8 @@ export class SessionDataManager implements ISessionDataManager
this._uiFlags = event.flags;
GetEventDispatcher().dispatchEvent(new SessionDataPreferencesEvent(this._uiFlags));
this.invalidateUserDataSnapshot();
};
GetEventDispatcher().addEventListener<NitroSettingsEvent>(NitroSettingsEvent.SETTINGS_UPDATED, this._settingsEventCallback);
@@ -189,15 +274,38 @@ export class SessionDataManager implements ISessionDataManager
this._safetyLocked = userInfo.safetyLocked;
this._ignoredUsersManager.requestIgnoredUsers(userInfo.username);
this.invalidateUserDataSnapshot();
}
private onUserPermissionsEvent(event: UserPermissionsEvent): void
{
if(!event || !event.connection) return;
this._clubLevel = event.getParser().clubLevel;
this._securityLevel = event.getParser().securityLevel;
this._isAmbassador = event.getParser().isAmbassador;
const parser = event.getParser();
this._clubLevel = parser.clubLevel;
this._securityLevel = parser.securityLevel;
this._isAmbassador = parser.isAmbassador;
this._rankId = parser.rankId;
this._rankName = parser.rankName;
this._rankBadge = parser.rankBadge;
this._rankPrefix = parser.rankPrefix;
this._rankPrefixColor = parser.rankPrefixColor;
// Copy into our local mutable Map so the parser's reference
// (which is overwritten on every parse() call) can't leak back
// to consumers.
this._permissions = new Map(parser.permissions);
// Invalidate BOTH snapshots: a UserPermissionsComposer push from
// the emulator refreshes user-data fields (clubLevel/securityLevel
// /rank metadata) AND the resolved permission map. Keep the two
// invalidation events distinct so React consumers can subscribe
// to just one (e.g. a widget that only cares about
// useHasPermission re-renders only when the map actually
// changes, not on every snapshot bump).
this.invalidateUserDataSnapshot();
this.invalidatePermissionsSnapshot();
}
private onAvailabilityStatusMessageEvent(event: AvailabilityStatusMessageEvent): void
@@ -211,6 +319,8 @@ export class SessionDataManager implements ISessionDataManager
this._systemOpen = parser.isOpen;
this._systemShutdown = parser.onShutdown;
this._isAuthenticHabbo = parser.isAuthenticUser;
this.invalidateUserDataSnapshot();
}
private onPetRespectFailed(event: PetScratchFailedMessageEvent): void
@@ -218,6 +328,8 @@ export class SessionDataManager implements ISessionDataManager
if(!event || !event.connection) return;
this._respectsPetLeft++;
this.invalidateUserDataSnapshot();
}
private onChangeNameUpdateEvent(event: ChangeUserNameResultMessageEvent): void
@@ -233,6 +345,8 @@ export class SessionDataManager implements ISessionDataManager
this._canChangeName = false;
GetEventDispatcher().dispatchEvent(new UserNameUpdateEvent(parser.name));
this.invalidateUserDataSnapshot();
}
private onUserNameChangeMessageEvent(event: UserNameChangeMessageEvent): void
@@ -249,6 +363,8 @@ export class SessionDataManager implements ISessionDataManager
this._canChangeName = false;
GetEventDispatcher().dispatchEvent(new UserNameUpdateEvent(this._name));
this.invalidateUserDataSnapshot();
}
private onUserTags(event: UserTagsMessageEvent): void
@@ -260,6 +376,8 @@ export class SessionDataManager implements ISessionDataManager
if(!parser) return;
this._tags = parser.tags;
this.invalidateUserDataSnapshot();
}
private onRoomModelNameEvent(event: RoomReadyMessageEvent): void
@@ -300,6 +418,8 @@ export class SessionDataManager implements ISessionDataManager
this._noobnessLevel = event.getParser().noobnessLevel;
if(this._noobnessLevel !== NoobnessLevelEnum.OLD_IDENTITY) GetConfiguration().setValue<number>('new.identity', 1);
this.invalidateUserDataSnapshot();
}
private onAccountSafetyLockStatusChangeMessageEvent(event: AccountSafetyLockStatusChangeMessageEvent): void
@@ -316,6 +436,8 @@ export class SessionDataManager implements ISessionDataManager
private onEmailStatus(event: EmailStatusResultEvent): void
{
this._isEmailVerified = event?.getParser()?.isVerified ?? false;
this.invalidateUserDataSnapshot();
}
public getFloorItemData(id: number): IFurnitureData
@@ -476,6 +598,8 @@ export class SessionDataManager implements ISessionDataManager
this.send(new UserRespectComposer(userId));
this._respectsLeft--;
this.invalidateUserDataSnapshot();
}
public givePetRespect(petId: number): void
@@ -485,6 +609,8 @@ export class SessionDataManager implements ISessionDataManager
this.send(new PetRespectComposer(petId));
this._respectsPetLeft--;
this.invalidateUserDataSnapshot();
}
public sendSpecialCommandMessage(text: string, styleId: number = 0): void
+46 -3
View File
@@ -1,5 +1,6 @@
import { IRoomUserData, IUserDataManager } from '@nitrots/api';
import { GetCommunication, RequestPetInfoComposer, UserCurrentBadgesComposer } from '@nitrots/communication';
import { GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
export class UserDataManager implements IUserDataManager
{
@@ -11,6 +12,23 @@ export class UserDataManager implements IUserDataManager
private _userDataByType: Map<number, Map<number, IRoomUserData>> = new Map();
private _userDataByRoomIndex: Map<number, IRoomUserData> = new Map();
private _userBadges: Map<number, string[]> = new Map();
private _roomUserListSnapshot: ReadonlyArray<IRoomUserData> | null = null;
private invalidateRoomUserListSnapshot(): void
{
this._roomUserListSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.ROOM_USER_LIST_UPDATED));
}
public getRoomUserListSnapshot(): ReadonlyArray<IRoomUserData>
{
if(this._roomUserListSnapshot) return this._roomUserListSnapshot;
this._roomUserListSnapshot = Object.freeze<IRoomUserData[]>([ ...this._userDataByRoomIndex.values() ]) as ReadonlyArray<IRoomUserData>;
return this._roomUserListSnapshot;
}
public getUserData(webID: number): IRoomUserData
{
@@ -84,6 +102,8 @@ export class UserDataManager implements IUserDataManager
existingType.set(data.webID, data);
this._userDataByRoomIndex.set(data.roomIndex, data);
this.invalidateRoomUserListSnapshot();
}
public removeUserData(roomIndex: number): void
@@ -97,6 +117,8 @@ export class UserDataManager implements IUserDataManager
const existingType = this._userDataByType.get(existing.type);
if(existingType) existingType.delete(existing.webID);
this.invalidateRoomUserListSnapshot();
}
public getUserBadges(userId: number): string[]
@@ -125,6 +147,8 @@ export class UserDataManager implements IUserDataManager
userData.sex = sex;
userData.hasSaddle = hasSaddle;
userData.isRiding = isRiding;
this.invalidateRoomUserListSnapshot();
}
public updateName(roomIndex: number, name: string): void
@@ -134,6 +158,8 @@ export class UserDataManager implements IUserDataManager
if(!userData) return;
userData.name = name;
this.invalidateRoomUserListSnapshot();
}
public updateMotto(roomIndex: number, custom: string): void
@@ -143,6 +169,8 @@ export class UserDataManager implements IUserDataManager
if(!userData) return;
userData.custom = custom;
this.invalidateRoomUserListSnapshot();
}
public updateNickIcon(roomIndex: number, nickIcon: string): void
@@ -152,6 +180,8 @@ export class UserDataManager implements IUserDataManager
if(!userData) return;
userData.nickIcon = nickIcon;
this.invalidateRoomUserListSnapshot();
}
public updateCustomization(roomIndex: number, nickIcon: string, prefixText: string, prefixColor: string, prefixIcon: string, prefixEffect: string, prefixFont: string, displayOrder: string): void
@@ -167,9 +197,11 @@ export class UserDataManager implements IUserDataManager
userData.prefixEffect = prefixEffect;
userData.prefixFont = prefixFont;
userData.displayOrder = displayOrder;
this.invalidateRoomUserListSnapshot();
}
public updateBackground(roomIndex: number, background: number, stand: number, overlay: number, cardBackground: number = 0): void
public updateBackground(roomIndex: number, background: number, stand: number, overlay: number, cardBackground: number = 0, borderId: number = 0): void
{
const userData = this.getUserDataByIndex(roomIndex);
@@ -179,6 +211,9 @@ export class UserDataManager implements IUserDataManager
userData.stand = stand;
userData.overlay = overlay;
userData.cardBackground = cardBackground;
userData.borderId = borderId;
this.invalidateRoomUserListSnapshot();
}
public updateAchievementScore(roomIndex: number, score: number): void
@@ -188,13 +223,19 @@ export class UserDataManager implements IUserDataManager
if(!userData) return;
userData.activityPoints = score;
this.invalidateRoomUserListSnapshot();
}
public updatePetLevel(roomIndex: number, level: number): void
{
const userData = this.getUserDataByIndex(roomIndex);
if(userData) userData.petLevel = level;
if(!userData) return;
userData.petLevel = level;
this.invalidateRoomUserListSnapshot();
}
public updatePetBreedingStatus(roomIndex: number, canBreed: boolean, canHarvest: boolean, canRevive: boolean, hasBreedingPermission: boolean): void
@@ -207,6 +248,8 @@ export class UserDataManager implements IUserDataManager
userData.canHarvest = canHarvest;
userData.canRevive = canRevive;
userData.hasBreedingPermission = hasBreedingPermission;
this.invalidateRoomUserListSnapshot();
}
public requestPetInfo(id: number): void
@@ -168,6 +168,6 @@ export class RoomChatHandler extends BaseHandler
if(!parser) return;
GetEventDispatcher().dispatchEvent(new RoomSessionChatEvent(RoomSessionChatEvent.CHAT_EVENT, session, session.ownRoomIndex, '', RoomSessionChatEvent.CHAT_TYPE_MUTE_REMAINING, SystemChatStyleEnum.GENERIC, [], null, parser.seconds));
GetEventDispatcher().dispatchEvent(new RoomSessionChatEvent(RoomSessionChatEvent.CHAT_EVENT, session, session.ownRoomIndex, '', RoomSessionChatEvent.CHAT_TYPE_MUTE_REMAINING, SystemChatStyleEnum.GENERIC, '', [], parser.seconds));
}
}
@@ -66,6 +66,7 @@ export class RoomUsersHandler extends BaseHandler
userData.stand = user.stand;
userData.overlay = user.overlay;
userData.cardBackground = user.cardBackground;
userData.borderId = user.borderId;
userData.activityPoints = user.activityPoints;
userData.figure = user.figure;
userData.type = user.userType;
@@ -115,9 +116,9 @@ export class RoomUsersHandler extends BaseHandler
session.userDataManager.updateCustomization(parser.unitId, parser.nickIcon || '', parser.prefixText || '', parser.prefixColor || '', parser.prefixIcon || '', parser.prefixEffect || '', parser.prefixFont || '', parser.displayOrder || 'icon-prefix-name');
session.userDataManager.updateAchievementScore(parser.unitId, parser.achievementScore);
session.userDataManager.updateBackground(parser.unitId, parser.backgroundId, parser.standId, parser.overlayId, parser.cardBackgroundId);
session.userDataManager.updateBackground(parser.unitId, parser.backgroundId, parser.standId, parser.overlayId, parser.cardBackgroundId, parser.borderId);
GetEventDispatcher().dispatchEvent(new RoomSessionUserFigureUpdateEvent(session, parser.unitId, parser.figure, parser.gender, parser.motto, parser.achievementScore, parser.backgroundId, parser.standId, parser.overlayId, parser.cardBackgroundId, parser.nickIcon || '', parser.prefixText || '', parser.prefixColor || '', parser.prefixIcon || '', parser.prefixEffect || '', parser.prefixFont || '', parser.displayOrder || 'icon-prefix-name'));
GetEventDispatcher().dispatchEvent(new RoomSessionUserFigureUpdateEvent(session, parser.unitId, parser.figure, parser.gender, parser.motto, parser.achievementScore, parser.backgroundId, parser.standId, parser.overlayId, parser.cardBackgroundId, parser.nickIcon || '', parser.prefixText || '', parser.prefixColor || '', parser.prefixIcon || '', parser.prefixEffect || '', parser.prefixFont || '', parser.displayOrder || 'icon-prefix-name', parser.borderId));
}
+1 -1
View File
@@ -14,6 +14,6 @@
"pixi.js": "^8.8.1"
},
"devDependencies": {
"typescript": "~5.5.4"
"typescript": "^6.0.3"
}
}
+45 -7
View File
@@ -1,6 +1,6 @@
import { IAdvancedMap, IMusicController, INitroEvent, ISoundManager } from '@nitrots/api';
import { IAdvancedMap, IMusicController, INitroEvent, ISoundManager, ISoundVolumesSnapshot } from '@nitrots/api';
import { GetConfiguration } from '@nitrots/configuration';
import { GetEventDispatcher, NitroSettingsEvent, NitroSoundEvent, RoomEngineEvent, RoomEngineObjectEvent, RoomEngineSamplePlaybackEvent } from '@nitrots/events';
import { GetEventDispatcher, NitroEvent, NitroEventType, NitroSettingsEvent, NitroSoundEvent, RoomEngineEvent, RoomEngineObjectEvent, RoomEngineSamplePlaybackEvent } from '@nitrots/events';
import { AdvancedMap, NitroLogger } from '@nitrots/utils';
import { MusicController } from './music/MusicController';
@@ -9,6 +9,7 @@ export class SoundManager implements ISoundManager
private _volumeSystem: number = 0.5;
private _volumeFurni: number = 0.5;
private _volumeTrax: number = 0.5;
private _volumesSnapshot: Readonly<ISoundVolumesSnapshot> | null = null;
private _internalSamples: IAdvancedMap<string, HTMLAudioElement> = new AdvancedMap();
private _furniSamples: IAdvancedMap<number, HTMLAudioElement> = new AdvancedMap();
@@ -81,17 +82,24 @@ export class SoundManager implements ISoundManager
case NitroSettingsEvent.SETTINGS_UPDATED: {
const castedEvent = (event as NitroSettingsEvent);
const volumeFurniUpdated = castedEvent.volumeFurni !== this._volumeFurni;
const volumeTraxUpdated = castedEvent.volumeTrax !== this._volumeTrax;
const nextSystem = (castedEvent.volumeSystem / 100);
const nextFurni = (castedEvent.volumeFurni / 100);
const nextTrax = (castedEvent.volumeTrax / 100);
this._volumeSystem = (castedEvent.volumeSystem / 100);
this._volumeFurni = (castedEvent.volumeFurni / 100);
this._volumeTrax = (castedEvent.volumeTrax / 100);
const volumeSystemUpdated = nextSystem !== this._volumeSystem;
const volumeFurniUpdated = nextFurni !== this._volumeFurni;
const volumeTraxUpdated = nextTrax !== this._volumeTrax;
this._volumeSystem = nextSystem;
this._volumeFurni = nextFurni;
this._volumeTrax = nextTrax;
if(volumeFurniUpdated) this.updateFurniSamplesVolume(this._volumeFurni);
if(volumeTraxUpdated) this._musicController?.updateVolume(this._volumeTrax);
if(volumeSystemUpdated || volumeFurniUpdated || volumeTraxUpdated) this.invalidateVolumesSnapshot();
return;
}
case NitroSoundEvent.PLAY_SOUND: {
@@ -215,8 +223,38 @@ export class SoundManager implements ISoundManager
return this._volumeTrax;
}
public get systemVolume(): number
{
return this._volumeSystem;
}
public get furniVolume(): number
{
return this._volumeFurni;
}
public get musicController(): IMusicController
{
return this._musicController;
}
private invalidateVolumesSnapshot(): void
{
this._volumesSnapshot = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOUND_VOLUMES_UPDATED));
}
public getVolumesSnapshot(): Readonly<ISoundVolumesSnapshot>
{
if(this._volumesSnapshot) return this._volumesSnapshot;
this._volumesSnapshot = Object.freeze<ISoundVolumesSnapshot>({
system: this._volumeSystem,
furni: this._volumeFurni,
trax: this._volumeTrax
});
return this._volumesSnapshot;
}
}
+1 -1
View File
@@ -16,6 +16,6 @@
},
"devDependencies": {
"@types/pako": "^2.0.3",
"typescript": "~5.5.4"
"typescript": "^6.0.3"
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
export const ArrayBufferToBase64 = (buffer: ArrayBuffer) =>
export const ArrayBufferToBase64 = (buffer: ArrayBufferLike | Uint8Array) =>
{
let binary = '';
const bytes = new Uint8Array(buffer);
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
const len = bytes.byteLength;
for(let i = 0; i < len; i++) (binary += String.fromCharCode(bytes[i]));
+2 -2
View File
@@ -13,7 +13,7 @@ export class BinaryReader implements IBinaryReader
public readBytes(length: number): IBinaryReader
{
const buffer = new BinaryReader(this._dataView.buffer.slice(this._position, this._position + length));
const buffer = new BinaryReader(this._dataView.buffer.slice(this._position, this._position + length) as ArrayBuffer);
this._position += length;
@@ -77,6 +77,6 @@ export class BinaryReader implements IBinaryReader
public toArrayBuffer(): ArrayBuffer
{
return this._dataView.buffer;
return this._dataView.buffer as ArrayBuffer;
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ export class BinaryWriter implements IBinaryWriter
public getBuffer(): ArrayBuffer
{
return this._buffer.buffer;
return this._buffer.buffer as ArrayBuffer;
}
public get position(): number
+69 -40
View File
@@ -1,4 +1,5 @@
import { fetchConfigJson } from './JsonParser';
import { ConfigJsonError, fetchConfigJson, isMissingResource } from './JsonParser';
import { NitroLogger } from './NitroLogger';
export const DEFAULT_TIERS = [ 'core', 'custom', 'seasonal' ] as const;
export type GamedataTier = typeof DEFAULT_TIERS[number] | string;
@@ -28,51 +29,69 @@ const joinUrl = (base: string, path: string): string =>
return `${ cleanBase }${ cleanPath }`;
};
const tryFetchOrNull = async <T = any>(url: string): Promise<T | null> =>
// Returns the parsed payload when the manifest exists, null on a clean 404.
// Re-throws on any other error (network failure, 5xx, parse error) so callers
// don't silently skip a tier because of a typo in manifest.json5.
const tryFetchManifest = async <T = any>(url: string): Promise<T | null> =>
{
try
{
return await fetchConfigJson<T>(url);
}
catch
catch(err)
{
return null;
if(isMissingResource(err)) return null;
throw err;
}
};
// Try .json5 first, then .json — both treated as optional. Anything other
// than 404 on either bubbles up.
const tryFetchManifestPair = async <T = any>(baseUrl: string, name: string): Promise<T | null> =>
{
const json5 = await tryFetchManifest<T>(joinUrl(baseUrl, `${ name }.json5`));
if(json5 !== null) return json5;
return await tryFetchManifest<T>(joinUrl(baseUrl, `${ name }.json`));
};
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 =>
const arrayItemsLookKeyed = (arr: any[], idKeys: readonly string[], sourceLabel?: string): string | null =>
{
if(!arr.length) return null;
for(const key of idKeys)
{
let allHave = true;
let have = 0;
for(const item of arr)
{
if(!isPlainObject(item) || item[key] === undefined || item[key] === null)
{
allHave = false;
break;
}
if(isPlainObject(item) && item[key] !== undefined && item[key] !== null) have++;
}
if(allHave) return key;
if(have === arr.length) return key;
// Heuristic: if most items are keyed but a few are not, the data is
// probably keyed and the outliers are bugs in the source data.
// Surface this so operators don't get silent duplicates after merge.
if(have > 0 && have / arr.length >= 0.8)
{
NitroLogger.warn(`mergeGamedata: ${ sourceLabel ? `${ sourceLabel }: ` : '' }array looks keyed by "${ key }" (${ have }/${ arr.length } items) but some entries are missing it — falling back to concat which may produce duplicates`);
}
}
return null;
};
export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAULT_ID_KEYS): any =>
export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAULT_ID_KEYS, sourceLabel?: string): 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);
const idKey = arrayItemsLookKeyed(a, idKeys, sourceLabel) || arrayItemsLookKeyed(b, idKeys, sourceLabel);
if(!idKey) return a.concat(b);
@@ -92,7 +111,7 @@ export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAUL
if(at !== undefined)
{
out[at] = mergeGamedata(out[at], item, idKeys);
out[at] = mergeGamedata(out[at], item, idKeys, sourceLabel);
}
else
{
@@ -110,7 +129,7 @@ export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAUL
for(const k of Object.keys(b))
{
out[k] = mergeGamedata(a[k], b[k], idKeys);
out[k] = mergeGamedata(a[k], b[k], idKeys, sourceLabel);
}
return out;
@@ -130,6 +149,11 @@ interface RootManifest
files?: string[];
}
// Load every file in `files` concurrently, return them in the original
// declared order so the merge step preserves override semantics.
const fetchFilesInOrder = async (baseUrl: string, files: readonly string[]): Promise<any[]> =>
Promise.all(files.map(file => fetchConfigJson(joinUrl(baseUrl, file))));
export const loadGamedata = async <T = any>(url: string, options: GamedataLoadOptions = {}): Promise<T> =>
{
if(!url) throw new Error('loadGamedata: empty URL');
@@ -140,42 +164,47 @@ export const loadGamedata = async <T = any>(url: string, options: GamedataLoadOp
}
const idKeys = options.mergeArrayIdKeys ?? DEFAULT_ID_KEYS;
const rootManifest = await tryFetchOrNull<RootManifest>(joinUrl(url, 'manifest.json5'))
?? await tryFetchOrNull<RootManifest>(joinUrl(url, 'manifest.json'));
const rootManifest = await tryFetchManifestPair<RootManifest>(url, 'manifest');
const tiers = (rootManifest?.tiers && rootManifest.tiers.length)
? rootManifest.tiers
: (options.tiers ?? DEFAULT_TIERS);
// Fetch root-level files in parallel with discovering each tier's
// manifest. Per-tier file batches stay sequenced relative to each other
// so override order (core → custom → seasonal) is preserved during
// merge, but fetches inside a tier batch run concurrently.
const [ rootParts, tierManifests ] = await Promise.all([
rootManifest?.files?.length ? fetchFilesInOrder(url, rootManifest.files) : Promise.resolve([] as any[]),
Promise.all(tiers.map(async tier =>
{
const tierUrl = joinUrl(url, `${ tier }/`);
const manifest = await tryFetchManifestPair<TierManifest>(tierUrl, 'manifest');
return { tier, tierUrl, manifest };
}))
]);
let merged: any = undefined;
if(rootManifest?.files?.length)
for(const part of rootParts)
{
for(const file of rootManifest.files)
merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys, url);
}
for(const { tier, tierUrl, manifest } of tierManifests)
{
if(!manifest?.files?.length) continue;
const parts = await fetchFilesInOrder(tierUrl, manifest.files);
for(const part of parts)
{
const fileUrl = joinUrl(url, file);
const part = await fetchConfigJson(fileUrl);
merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys);
merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys, `${ url } (${ tier })`);
}
}
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`);
if(merged === undefined) throw new ConfigJsonError(`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`, 'fetch', url);
return merged as T;
};
+43 -9
View File
@@ -5,6 +5,28 @@ declare const __NITRO_JSON_MODE__: 'legacy' | 'json5' | 'auto' | undefined;
const JSON5_EXTENSION = /\.json5(?:[?#]|$)/i;
const JSON5_MIME = /(?:application|text)\/(?:json5|x-json5)/i;
export type ConfigJsonErrorPhase = 'fetch' | 'parse';
export class ConfigJsonError extends Error
{
public readonly phase: ConfigJsonErrorPhase;
public readonly sourceUrl: string;
public readonly httpStatus?: number;
constructor(message: string, phase: ConfigJsonErrorPhase, sourceUrl: string, httpStatus?: number, cause?: unknown)
{
super(message);
this.name = 'ConfigJsonError';
this.phase = phase;
this.sourceUrl = sourceUrl;
this.httpStatus = httpStatus;
if(cause !== undefined) (this as any).cause = cause;
}
}
export const isMissingResource = (err: unknown): boolean =>
err instanceof ConfigJsonError && err.phase === 'fetch' && err.httpStatus === 404;
const resolveJsonMode = (): 'legacy' | 'json5' | 'auto' =>
{
try
@@ -44,9 +66,7 @@ const formatStrictError = (sourceUrl: string, err: unknown): string =>
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 trimmed = text ?? '';
const mode = resolveJsonMode();
if(mode === 'legacy')
@@ -57,7 +77,7 @@ export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''):
}
catch(err)
{
throw new Error(formatStrictError(sourceUrl, err));
throw new ConfigJsonError(formatStrictError(sourceUrl, err), 'parse', sourceUrl, undefined, err);
}
}
@@ -69,7 +89,7 @@ export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''):
}
catch(err)
{
throw new Error(formatParseError(sourceUrl, err, err));
throw new ConfigJsonError(formatParseError(sourceUrl, err, err), 'parse', sourceUrl, undefined, err);
}
}
@@ -90,7 +110,7 @@ export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''):
}
catch(json5Error)
{
throw new Error(formatParseError(sourceUrl, strictError, json5Error));
throw new ConfigJsonError(formatParseError(sourceUrl, strictError, json5Error), 'parse', sourceUrl, undefined, json5Error);
}
};
@@ -109,7 +129,7 @@ export const parseConfigJsonFromResponse = async <T = any>(response: Response, s
}
catch(err)
{
throw new Error(formatParseError(url, err, err));
throw new ConfigJsonError(formatParseError(url, err, err), 'parse', url, undefined, err);
}
}
@@ -118,9 +138,23 @@ export const parseConfigJsonFromResponse = async <T = any>(response: Response, s
export const fetchConfigJson = async <T = any>(url: string, init?: RequestInit): Promise<T> =>
{
const response = await fetch(url, init);
let response: Response | undefined;
if(!response || response.status !== 200) throw new Error(`Failed to fetch "${ url }" — server returned HTTP ${ response?.status ?? 'no response' }`);
try
{
response = await fetch(url, init);
}
catch(networkErr)
{
const message = (networkErr as Error)?.message || String(networkErr);
throw new ConfigJsonError(`Network error fetching "${ url }" — ${ message }`, 'fetch', url, undefined, networkErr);
}
if(!response || response.status !== 200)
{
const status = response?.status;
throw new ConfigJsonError(`Failed to fetch "${ url }" — server returned HTTP ${ status ?? 'no response' }`, 'fetch', url, status);
}
return parseConfigJsonFromResponse<T>(response, url);
};
+4 -4
View File
@@ -2,8 +2,8 @@ export { };
declare global
{
interface Window
{
NitroConfig?: { [index: string]: any };
}
interface Window
{
NitroConfig?: Record<string, unknown>;
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
export class NitroVersion
{
public static RENDERER_VERSION: string = '3.0.0';
public static UI_VERSION: string = '3.0.4';
public static RENDERER_VERSION: string = '3.5.0';
public static UI_VERSION: string = '3.5.0';
public static sayHello(): void
{
+1 -1
View File
@@ -28,7 +28,7 @@ export class TextureUtils
try
{
return await this.getExtractor().image(options);
return await this.getExtractor().image(options) as HTMLImageElement;
}
catch(e)
{
@@ -0,0 +1,325 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { BinaryReader } from '../BinaryReader';
import { BinaryWriter } from '../BinaryWriter';
const concatBuffers = (...parts: ArrayBuffer[]): ArrayBuffer =>
{
const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
const out = new Uint8Array(total);
let offset = 0;
for(const part of parts)
{
out.set(new Uint8Array(part), offset);
offset += part.byteLength;
}
return out.buffer;
};
describe('BinaryReader / BinaryWriter', () =>
{
let writer: BinaryWriter;
beforeEach(() =>
{
writer = new BinaryWriter();
});
describe('byte round-trip', () =>
{
it('writes and reads a single byte', () =>
{
writer.writeByte(0x42);
const reader = new BinaryReader(writer.getBuffer());
expect(reader.readByte()).toBe(0x42);
expect(reader.remaining()).toBe(0);
});
it('readByte returns a signed int8 (values above 127 wrap negative)', () =>
{
writer.writeByte(0xFF);
const reader = new BinaryReader(writer.getBuffer());
expect(reader.readByte()).toBe(-1);
});
it('writeByte chains', () =>
{
writer.writeByte(1).writeByte(2).writeByte(3);
const reader = new BinaryReader(writer.getBuffer());
expect(reader.readByte()).toBe(1);
expect(reader.readByte()).toBe(2);
expect(reader.readByte()).toBe(3);
});
});
describe('short round-trip (16-bit big-endian)', () =>
{
it('writes and reads a positive short', () =>
{
writer.writeShort(0x1234);
const reader = new BinaryReader(writer.getBuffer());
expect(reader.readShort()).toBe(0x1234);
});
it('round-trips the int16 boundary values', () =>
{
writer.writeShort(32767).writeShort(-1);
const reader = new BinaryReader(writer.getBuffer());
expect(reader.readShort()).toBe(32767);
expect(reader.readShort()).toBe(-1);
});
it('emits big-endian byte order', () =>
{
writer.writeShort(0x0102);
const bytes = new Uint8Array(writer.getBuffer());
expect(bytes[0]).toBe(0x01);
expect(bytes[1]).toBe(0x02);
});
});
describe('int round-trip (32-bit big-endian)', () =>
{
it('writes and reads a positive int', () =>
{
writer.writeInt(123456789);
const reader = new BinaryReader(writer.getBuffer());
expect(reader.readInt()).toBe(123456789);
});
it('round-trips the int32 boundaries (max / min / -1)', () =>
{
writer.writeInt(2147483647).writeInt(-2147483648).writeInt(-1);
const reader = new BinaryReader(writer.getBuffer());
expect(reader.readInt()).toBe(2147483647);
expect(reader.readInt()).toBe(-2147483648);
expect(reader.readInt()).toBe(-1);
});
it('emits big-endian byte order', () =>
{
writer.writeInt(0x01020304);
const bytes = new Uint8Array(writer.getBuffer());
expect(bytes[0]).toBe(0x01);
expect(bytes[1]).toBe(0x02);
expect(bytes[2]).toBe(0x03);
expect(bytes[3]).toBe(0x04);
});
});
describe('string round-trip', () =>
{
it('writes a length-prefixed string and decodes it back via readShort + readBytes', () =>
{
writer.writeString('hello');
const reader = new BinaryReader(writer.getBuffer());
const length = reader.readShort();
expect(length).toBe(5);
expect(reader.readBytes(length).toString()).toBe('hello');
});
it('round-trips UTF-8 multibyte characters with correct byte length', () =>
{
// 'café' = 5 bytes UTF-8 (c, a, 0xC3 0xA9, ASCII finale)
writer.writeString('café');
const reader = new BinaryReader(writer.getBuffer());
const length = reader.readShort();
expect(length).toBe(5);
expect(reader.readBytes(length).toString()).toBe('café');
});
it('writeString with includeLength=false omits the length prefix', () =>
{
writer.writeString('xy', false);
const buf = writer.getBuffer();
expect(buf.byteLength).toBe(2);
expect(new Uint8Array(buf)[0]).toBe(0x78); // 'x'
expect(new Uint8Array(buf)[1]).toBe(0x79); // 'y'
});
it('round-trips the empty string', () =>
{
writer.writeString('');
const reader = new BinaryReader(writer.getBuffer());
expect(reader.readShort()).toBe(0);
expect(reader.remaining()).toBe(0);
});
});
describe('writeBytes', () =>
{
it('appends a number[] payload', () =>
{
writer.writeBytes([ 0x10, 0x20, 0x30 ]);
const reader = new BinaryReader(writer.getBuffer());
expect(reader.readByte()).toBe(0x10);
expect(reader.readByte()).toBe(0x20);
expect(reader.readByte()).toBe(0x30);
});
it('appends an ArrayBuffer payload', () =>
{
const payload = new Uint8Array([ 0xAA, 0xBB ]).buffer;
writer.writeBytes(payload);
const reader = new BinaryReader(writer.getBuffer());
expect(reader.readByte()).toBe(-86); // 0xAA as int8
expect(reader.readByte()).toBe(-69); // 0xBB as int8
});
});
describe('readBytes slice', () =>
{
it('returns an independent reader over the requested slice', () =>
{
writer.writeInt(0xCAFEBABE | 0).writeInt(0xDEADBEEF | 0);
const reader = new BinaryReader(writer.getBuffer());
const sliced = reader.readBytes(4);
// The slice's position is independent of the outer reader.
expect(sliced.readInt()).toBe(0xCAFEBABE | 0);
// The outer reader advanced by 4 and can still read the second int.
expect(reader.readInt()).toBe(0xDEADBEEF | 0);
});
});
describe('remaining accounting', () =>
{
it('decrements by the read size and reaches 0 at the end of the buffer', () =>
{
writer.writeByte(1).writeShort(2).writeInt(3);
const reader = new BinaryReader(writer.getBuffer());
expect(reader.remaining()).toBe(7);
reader.readByte();
expect(reader.remaining()).toBe(6);
reader.readShort();
expect(reader.remaining()).toBe(4);
reader.readInt();
expect(reader.remaining()).toBe(0);
});
});
describe('float / double read', () =>
{
// BinaryWriter has no write counterparts for float/double — build the
// buffer by hand via DataView and check the reader decodes correctly.
it('readFloat decodes an IEEE-754 single-precision big-endian value', () =>
{
const buf = new ArrayBuffer(4);
new DataView(buf).setFloat32(0, 3.5, false);
const reader = new BinaryReader(buf);
expect(reader.readFloat()).toBeCloseTo(3.5, 5);
expect(reader.remaining()).toBe(0);
});
it('readDouble decodes an IEEE-754 double-precision big-endian value', () =>
{
const buf = new ArrayBuffer(8);
new DataView(buf).setFloat64(0, Math.PI, false);
const reader = new BinaryReader(buf);
expect(reader.readDouble()).toBeCloseTo(Math.PI, 12);
expect(reader.remaining()).toBe(0);
});
});
describe('writer position getter/setter', () =>
{
it('reports the position after writes', () =>
{
writer.writeInt(0).writeShort(0);
expect(writer.position).toBe(6);
});
it('position can be set explicitly (caller-managed reposition)', () =>
{
writer.writeInt(0);
writer.position = 0;
expect(writer.position).toBe(0);
});
});
describe('typical packet round-trip (header + payload)', () =>
{
it('encodes and decodes a header + mixed payload (short + int + string)', () =>
{
const header = 1234;
const userId = 99999;
const username = 'simoleo';
writer
.writeShort(header)
.writeInt(userId)
.writeString(username);
const reader = new BinaryReader(writer.getBuffer());
expect(reader.readShort()).toBe(header);
expect(reader.readInt()).toBe(userId);
const nameLength = reader.readShort();
const name = reader.readBytes(nameLength).toString();
expect(name).toBe(username);
expect(reader.remaining()).toBe(0);
});
it('concatenated buffers round-trip across independent writer instances', () =>
{
const a = new BinaryWriter();
const b = new BinaryWriter();
a.writeInt(11);
b.writeInt(22);
const reader = new BinaryReader(concatBuffers(a.getBuffer(), b.getBuffer()));
expect(reader.readInt()).toBe(11);
expect(reader.readInt()).toBe(22);
expect(reader.remaining()).toBe(0);
});
});
});