Merge remote-tracking branch 'origin/main'

# Conflicts:
#	packages/communication/src/NitroMessages.ts
This commit is contained in:
Lorenzune
2026-03-21 14:49:08 +01:00
51 changed files with 1469 additions and 120 deletions
@@ -5,6 +5,7 @@ import { IMessageEvent } from './IMessageEvent';
export interface IConnection
{
init(socketUrl: string): void;
dispose(): void;
ready(): void;
authenticated(): void;
send(...composers: IMessageComposer<unknown[]>[]): void;
@@ -7,5 +7,6 @@ export interface IRoomSessionManager
createSession(roomId: number, password?: string): boolean;
startSession(session: IRoomSession): boolean;
removeSession(id: number, openLandingView?: boolean): void;
tryRestoreSession(): boolean;
viewerSession: IRoomSession;
}
+48 -22
View File
@@ -77,18 +77,9 @@ export class AssetManager implements IAssetManager
{
if(!urls || !urls.length) return Promise.resolve(true);
try
{
await Promise.all(urls.map(url => this.downloadAsset(url)));
await Promise.all(urls.map(url => this.downloadAsset(url)));
return true;
}
catch (err)
{
NitroLogger.error(err);
}
return false;
return true;
}
public async downloadAsset(url: string): Promise<boolean>
@@ -123,9 +114,18 @@ export class AssetManager implements IAssetManager
if(url.endsWith('.nitro') || url.endsWith('.gif'))
{
const response = await fetch(url);
let response: Response;
if(!response || response.status !== 200) return false;
try
{
response = await fetch(url);
}
catch(fetchErr)
{
throw new Error(`Could not fetch "${ url }" — is the URL correct and the server reachable? (${ fetchErr.message })`);
}
if(!response || response.status !== 200) throw new Error(`Failed to load "${ url }" — server returned HTTP ${ response?.status ?? 'no response' }`);
const arrayBuffer = await response.arrayBuffer();
@@ -137,19 +137,47 @@ export class AssetManager implements IAssetManager
}
else
{
const animatedGif = AnimatedGIF.fromBuffer(arrayBuffer);
const texture = animatedGif.texture;
try
{
const animatedGif = AnimatedGIF.fromBuffer(arrayBuffer);
const texture = animatedGif.texture;
if(texture) this.setTexture(url, texture);
if(texture) this.setTexture(url, texture);
}
catch(gifErr)
{
const texture = await Assets.load<Texture>(url);
if(texture) this.setTexture(url, texture);
}
}
}
else if(url.endsWith('.json'))
{
const response = await fetch(url);
let response: Response;
if(!response || response.status !== 200) return false;
try
{
response = await fetch(url);
}
catch(fetchErr)
{
throw new Error(`Could not fetch "${ url }" — is the URL correct and the server reachable? (${ fetchErr.message })`);
}
if(!response || response.status !== 200) throw new Error(`Failed to load "${ url }" — server returned HTTP ${ response?.status ?? 'no response' }`);
let data: IAssetData;
try
{
data = await response.json() as IAssetData;
}
catch(parseErr)
{
throw new Error(`Invalid JSON in "${ url }" — the URL may be wrong and returning an HTML page instead of JSON (${ parseErr.message })`);
}
const data = await response.json() as IAssetData;
let texture: Texture = null;
const imagePath = data?.spritesheet?.meta?.image;
const fallbackImagePath = ((data?.name && data.name.length > 0)
@@ -174,9 +202,7 @@ export class AssetManager implements IAssetManager
}
catch (err)
{
NitroLogger.error(err);
return false;
throw new Error(`Asset loading failed for "${ url }": ${ err.message || err }`);
}
}
@@ -29,13 +29,31 @@ export class AvatarAssetDownloadManager
const url = GetConfiguration().getValue<string>('avatar.figuremap.url');
if(!url || !url.length) throw new Error('Invalid figure map url');
if(!url || !url.length) throw new Error('Missing "avatar.figuremap.url" in config — add the figure map URL to your renderer-config.json');
const response = await fetch(url);
let response: Response;
if(response.status !== 200) throw new Error('Invalid figure map file');
try
{
response = await fetch(url);
}
catch(fetchErr)
{
throw new Error(`Could not fetch figure map from "${ url }" — check "avatar.figuremap.url" in renderer-config.json (${ fetchErr.message })`);
}
const responseData = await response.json();
if(response.status !== 200) throw new Error(`Failed to load figure map from "${ url }" — server returned HTTP ${ response.status }. Check "avatar.figuremap.url" in renderer-config.json`);
let responseData: any;
try
{
responseData = await response.json();
}
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 })`);
}
this.processFigureMap(responseData.libraries);
+40 -8
View File
@@ -70,13 +70,29 @@ export class AvatarRenderManager implements IAvatarRenderManager
const url = GetConfiguration().getValue<string>('avatar.actions.url');
if(!url || !url.length) throw new Error('Invalid avatar action url');
if(!url || !url.length) throw new Error('Missing "avatar.actions.url" in config — add the URL to your renderer-config.json');
const response = await fetch(url);
let response: Response;
if(response.status !== 200) throw new Error('Invalid avatar action file');
try
{
response = await fetch(url);
}
catch(fetchErr)
{
throw new Error(`Could not fetch avatar actions from "${ url }" — check "avatar.actions.url" in renderer-config.json (${ fetchErr.message })`);
}
this._structure.updateActions(await response.json());
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 })`);
}
}
private async loadFigureData(): Promise<void>
@@ -87,13 +103,29 @@ export class AvatarRenderManager implements IAvatarRenderManager
const url = GetConfiguration().getValue<string>('avatar.figuredata.url');
if(!url || !url.length) throw new Error('Invalid figure data url');
if(!url || !url.length) throw new Error('Missing "avatar.figuredata.url" in config — add the URL to your renderer-config.json');
const response = await fetch(url);
let response: Response;
if(response.status !== 200) throw new Error('Invalid figure data file');
try
{
response = await fetch(url);
}
catch(fetchErr)
{
throw new Error(`Could not fetch figure data from "${ url }" — check "avatar.figuredata.url" in renderer-config.json (${ fetchErr.message })`);
}
this._structure.figureData.appendJSON(await response.json());
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 })`);
}
this._structure.init();
}
@@ -29,13 +29,31 @@ export class EffectAssetDownloadManager
const url = GetConfiguration().getValue<string>('avatar.effectmap.url');
if(!url || !url.length) throw new Error('Invalid effect map url');
if(!url || !url.length) throw new Error('Missing "avatar.effectmap.url" in config — add the effect map URL to your renderer-config.json');
const response = await fetch(url);
let response: Response;
if(response.status !== 200) throw new Error('Invalid effect map file');
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 })`);
}
const responseData = await response.json();
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();
}
catch(parseErr)
{
throw new Error(`Invalid JSON in effect map "${ url }" — the URL may be wrong. Check "avatar.effectmap.url" in renderer-config.json (${ parseErr.message })`);
}
this.processEffectMap(responseData.effects);
@@ -1,7 +1,7 @@
import { ICommunicationManager, IConnection, IMessageConfiguration, IMessageEvent } from '@nitrots/api';
import { GetConfiguration } from '@nitrots/configuration';
import { GetEventDispatcher, NitroEventType } from '@nitrots/events';
import { GetTickerTime } from '@nitrots/utils';
import { GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
import { GetTickerTime, NitroLogger } from '@nitrots/utils';
import { NitroMessages } from './NitroMessages';
import { SocketConnection } from './SocketConnection';
import { AuthenticatedEvent, ClientHelloMessageComposer, ClientPingEvent, InfoRetrieveMessageComposer, PongMessageComposer, SSOTicketMessageComposer, UniqueIDMessageComposer } from './messages';
@@ -17,7 +17,11 @@ export class CommunicationManager implements ICommunicationManager
private _socketClosedCallback: () => void = null;
private _socketOpenedCallback: () => void = null;
private _socketErrorCallback: () => void = null;
private _socketReconnectedCallback: () => void = null;
private _machineId: string = null;
private _initResolved: boolean = false;
private getGpu(): string {
const e = document.createElement('canvas');
let t, s, i, r;
@@ -41,7 +45,7 @@ export class CommunicationManager implements ICommunicationManager
return '<mathroutines>Error</mathroutines>';
}
}
private getCanvas(): any {
const e = document.createElement('canvas'), t = e.getContext('2d'), userAgent = navigator.userAgent, screenInfo = '${window.screen.width}x${window.screen.height}', currentDate = new Date().toString(), s = 'ThiosIsVerrySeCuRe02938883721moreStuff! | ${userAgent} | ${screenInfo} | ${currentDate}';
t.textBaseline = 'top';
@@ -67,24 +71,33 @@ export class CommunicationManager implements ICommunicationManager
}
return r;
}
private generateMachineID(): string {
const fp = new ClientJS();
const uniqueId = fp.getCustomFingerprint(
fp.getAvailableResolution(),
fp.getAvailableResolution(),
fp.getOS(),
fp.getCPU(),
fp.getColorDepth(),
this.getGpu(),
fp.getSilverlightVersion(),
fp.getOSVersion(),
this.getMathResult(),
fp.getCanvasPrint(),
fp.getCPU(),
fp.getColorDepth(),
this.getGpu(),
fp.getSilverlightVersion(),
fp.getOSVersion(),
this.getMathResult(),
fp.getCanvasPrint(),
this.getCanvas()
);
return uniqueId == null ? 'FAILED' : `IID-${uniqueId}`;
}
private sendHandshake(): void
{
if(!this._machineId) this._machineId = this.generateMachineID();
this._connection.send(new ClientHelloMessageComposer(null, null, null, null));
this._connection.send(new SSOTicketMessageComposer(GetConfiguration().getValue('sso.ticket', null), GetTickerTime()));
this._connection.send(new UniqueIDMessageComposer(this._machineId, '', ''));
}
constructor()
{
this._connection.registerMessages(this._messages);
@@ -99,6 +112,17 @@ export class CommunicationManager implements ICommunicationManager
};
GetEventDispatcher().addEventListener(NitroEventType.SOCKET_CLOSED, this._socketClosedCallback);
// Handle reconnection - re-authenticate when socket reconnects
this._socketReconnectedCallback = () =>
{
NitroLogger.log('[CommunicationManager] Socket reconnected, re-authenticating...');
if(GetConfiguration().getValue<boolean>('system.pong.manually', false)) this.startPong();
this.sendHandshake();
};
GetEventDispatcher().addEventListener(NitroEventType.SOCKET_RECONNECTED, this._socketReconnectedCallback);
return new Promise((resolve, reject) =>
{
// Store callback for cleanup
@@ -106,18 +130,14 @@ export class CommunicationManager implements ICommunicationManager
{
if(GetConfiguration().getValue<boolean>('system.pong.manually', false)) this.startPong();
const machineId = this.generateMachineID();
this._connection.send(new ClientHelloMessageComposer(null, null, null, null));
this._connection.send(new SSOTicketMessageComposer(GetConfiguration().getValue('sso.ticket', null), GetTickerTime()));
this._connection.send(new UniqueIDMessageComposer(machineId, '', ''));
this.sendHandshake();
};
GetEventDispatcher().addEventListener(NitroEventType.SOCKET_OPENED, this._socketOpenedCallback);
// Store callback for cleanup
this._socketErrorCallback = () =>
{
reject();
if(!this._initResolved) reject();
};
GetEventDispatcher().addEventListener(NitroEventType.SOCKET_ERROR, this._socketErrorCallback);
@@ -125,11 +145,30 @@ export class CommunicationManager implements ICommunicationManager
const pingEvent = new ClientPingEvent((event: ClientPingEvent) => this.sendPong());
const authEvent = new AuthenticatedEvent((event: AuthenticatedEvent) =>
{
const isReconnect = this._initResolved;
NitroLogger.log('[CommunicationManager] AuthenticatedEvent received (isReconnect=' + isReconnect + ')');
this._connection.authenticated();
resolve();
if(!this._initResolved)
{
this._initResolved = true;
resolve();
}
if(isReconnect)
{
this._connection.ready();
}
event.connection.send(new InfoRetrieveMessageComposer());
if(isReconnect)
{
NitroLogger.log('[CommunicationManager] Dispatching SOCKET_REAUTHENTICATED');
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_REAUTHENTICATED));
}
});
this._messageEvents.push(pingEvent, authEvent);
@@ -164,6 +203,12 @@ export class CommunicationManager implements ICommunicationManager
this._socketErrorCallback = null;
}
if(this._socketReconnectedCallback)
{
GetEventDispatcher().removeEventListener(NitroEventType.SOCKET_RECONNECTED, this._socketReconnectedCallback);
this._socketReconnectedCallback = null;
}
// Remove message events
for(const event of this._messageEvents)
{
File diff suppressed because one or more lines are too long
+171 -18
View File
@@ -1,5 +1,5 @@
import { ICodec, IConnection, IMessageComposer, IMessageConfiguration, IMessageDataWrapper, IMessageEvent, WebSocketEventEnum } from '@nitrots/api';
import { GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
import { GetEventDispatcher, NitroEvent, NitroEventType, ReconnectEvent } from '@nitrots/events';
import { NitroLogger } from '@nitrots/utils';
import { EvaWireFormat } from './codec';
import { MessageClassManager } from './messages';
@@ -23,19 +23,39 @@ export class SocketConnection implements IConnection
private _onErrorCallback: (event: Event) => void = null;
private _onMessageCallback: (event: MessageEvent) => void = null;
// Reconnection state
private _socketUrl: string = null;
private _reconnectAttempt: number = 0;
private _reconnectTimer: ReturnType<typeof setTimeout> = null;
private _isReconnecting: boolean = false;
private _intentionalClose: boolean = false;
private _wasAuthenticated: boolean = false;
public static readonly MAX_RECONNECT_ATTEMPTS: number = 7;
public static readonly BASE_RECONNECT_DELAY_MS: number = 1000;
public static readonly MAX_RECONNECT_DELAY_MS: number = 30000;
public init(socketUrl: string): void
{
if(!socketUrl || !socketUrl.length) return;
this._socketUrl = socketUrl;
this._intentionalClose = false;
this.createSocket(socketUrl);
}
private createSocket(socketUrl: string): void
{
this._dataBuffer = new ArrayBuffer(0);
this._socket = new WebSocket(socketUrl);
this._socket.binaryType = 'arraybuffer';
// Store callbacks for cleanup
this._onOpenCallback = () => GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_OPENED));
this._onCloseCallback = () => GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_CLOSED));
this._onErrorCallback = () => GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_ERROR));
this._onOpenCallback = () => this.onSocketOpened();
this._onCloseCallback = (event: Event) => this.onSocketClosed(event as CloseEvent);
this._onErrorCallback = () => this.onSocketError();
this._onMessageCallback = (event: MessageEvent) =>
{
this._dataBuffer = this.concatArrayBuffers(this._dataBuffer, event.data);
@@ -48,29 +68,152 @@ export class SocketConnection implements IConnection
this._socket.addEventListener(WebSocketEventEnum.CONNECTION_MESSAGE, this._onMessageCallback);
}
public dispose(): void
private onSocketOpened(): void
{
if(this._socket)
if(this._isReconnecting)
{
// Remove all event listeners
if(this._onOpenCallback) this._socket.removeEventListener(WebSocketEventEnum.CONNECTION_OPENED, this._onOpenCallback);
if(this._onCloseCallback) this._socket.removeEventListener(WebSocketEventEnum.CONNECTION_CLOSED, this._onCloseCallback);
if(this._onErrorCallback) this._socket.removeEventListener(WebSocketEventEnum.CONNECTION_ERROR, this._onErrorCallback);
if(this._onMessageCallback) this._socket.removeEventListener(WebSocketEventEnum.CONNECTION_MESSAGE, this._onMessageCallback);
NitroLogger.log('[SocketConnection] Reconnected successfully after ' + this._reconnectAttempt + ' attempt(s)');
// Close socket if still open
if(this._socket.readyState === WebSocket.OPEN || this._socket.readyState === WebSocket.CONNECTING)
{
this._socket.close();
}
this._reconnectAttempt = 0;
this._isReconnecting = false;
this._socket = null;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_RECONNECTED));
}
else
{
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_OPENED));
}
}
private onSocketClosed(event: CloseEvent): void
{
NitroLogger.log('[SocketConnection] Socket closed, code: ' + (event?.code ?? 'unknown') + ', reason: ' + (event?.reason || 'none'));
if(this._intentionalClose)
{
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_CLOSED));
return;
}
const code = event?.code ?? 0;
if(code === 1000 || code === 1001)
{
NitroLogger.log('[SocketConnection] Server closed cleanly (code ' + code + ') - not reconnecting');
this._isAuthenticated = false;
this._isReady = false;
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_CLOSED));
return;
}
if(this._isAuthenticated) this._wasAuthenticated = true;
this._isAuthenticated = false;
this._isReady = false;
this._pendingClientMessages = [];
this._pendingServerMessages = [];
this.attemptReconnect();
}
private onSocketError(): void
{
if(this._isReconnecting)
{
NitroLogger.log('[SocketConnection] Reconnect attempt ' + this._reconnectAttempt + ' failed');
return;
}
if(!this._wasAuthenticated && !this._isAuthenticated)
{
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_ERROR));
}
}
private attemptReconnect(): void
{
if(this._reconnectAttempt >= SocketConnection.MAX_RECONNECT_ATTEMPTS)
{
NitroLogger.log('[SocketConnection] Max reconnect attempts reached (' + SocketConnection.MAX_RECONNECT_ATTEMPTS + ')');
this._isReconnecting = false;
this._wasAuthenticated = false;
GetEventDispatcher().dispatchEvent(new ReconnectEvent(
NitroEventType.SOCKET_RECONNECT_FAILED,
this._reconnectAttempt,
SocketConnection.MAX_RECONNECT_ATTEMPTS
));
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.SOCKET_CLOSED));
return;
}
this._isReconnecting = true;
this._reconnectAttempt++;
const delay = Math.min(
SocketConnection.BASE_RECONNECT_DELAY_MS * Math.pow(2, this._reconnectAttempt - 1) + Math.random() * 1000,
SocketConnection.MAX_RECONNECT_DELAY_MS
);
NitroLogger.log('[SocketConnection] Reconnecting in ' + Math.round(delay) + 'ms (attempt ' + this._reconnectAttempt + '/' + SocketConnection.MAX_RECONNECT_ATTEMPTS + ')');
GetEventDispatcher().dispatchEvent(new ReconnectEvent(
NitroEventType.SOCKET_RECONNECTING,
this._reconnectAttempt,
SocketConnection.MAX_RECONNECT_ATTEMPTS
));
this._reconnectTimer = setTimeout(() =>
{
this._reconnectTimer = null;
this.cleanupSocket();
this.createSocket(this._socketUrl);
}, delay);
}
private cleanupSocket(): void
{
if(!this._socket) return;
if(this._onOpenCallback) this._socket.removeEventListener(WebSocketEventEnum.CONNECTION_OPENED, this._onOpenCallback);
if(this._onCloseCallback) this._socket.removeEventListener(WebSocketEventEnum.CONNECTION_CLOSED, this._onCloseCallback);
if(this._onErrorCallback) this._socket.removeEventListener(WebSocketEventEnum.CONNECTION_ERROR, this._onErrorCallback);
if(this._onMessageCallback) this._socket.removeEventListener(WebSocketEventEnum.CONNECTION_MESSAGE, this._onMessageCallback);
if(this._socket.readyState === WebSocket.OPEN || this._socket.readyState === WebSocket.CONNECTING)
{
try { this._socket.close(); } catch(e) { /* ignore */ }
}
this._socket = null;
this._onOpenCallback = null;
this._onCloseCallback = null;
this._onErrorCallback = null;
this._onMessageCallback = null;
}
public dispose(): void
{
this._intentionalClose = true;
if(this._reconnectTimer)
{
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
this._isReconnecting = false;
this._reconnectAttempt = 0;
this._wasAuthenticated = false;
this.cleanupSocket();
this._pendingClientMessages = [];
this._pendingServerMessages = [];
@@ -142,7 +285,7 @@ export class SocketConnection implements IConnection
private write(buffer: ArrayBuffer): void
{
if(this._socket.readyState !== WebSocket.OPEN) return;
if(!this._socket || this._socket.readyState !== WebSocket.OPEN) return;
this._socket.send(buffer);
}
@@ -286,6 +429,16 @@ export class SocketConnection implements IConnection
return this._isAuthenticated;
}
public get isReconnecting(): boolean
{
return this._isReconnecting;
}
public get wasAuthenticated(): boolean
{
return this._wasAuthenticated;
}
public get dataBuffer(): ArrayBuffer
{
return this._dataBuffer;
+5
View File
@@ -15,6 +15,7 @@ export * from './messages/incoming/camera';
export * from './messages/incoming/campaign';
export * from './messages/incoming/catalog';
export * from './messages/incoming/client';
export * from './messages/incoming/commands';
export * from './messages/incoming/competition';
export * from './messages/incoming/crafting';
export * from './messages/incoming/desktop';
@@ -37,6 +38,7 @@ export * from './messages/incoming/inventory/clothes';
export * from './messages/incoming/inventory/furni';
export * from './messages/incoming/inventory/furni/gifts';
export * from './messages/incoming/inventory/pets';
export * from './messages/incoming/inventory/prefixes';
export * from './messages/incoming/inventory/trading';
export * from './messages/incoming/landingview';
export * from './messages/incoming/landingview/votes';
@@ -109,6 +111,7 @@ export * from './messages/outgoing/inventory/badges';
export * from './messages/outgoing/inventory/bots';
export * from './messages/outgoing/inventory/furni';
export * from './messages/outgoing/inventory/pets';
export * from './messages/outgoing/inventory/prefixes';
export * from './messages/outgoing/inventory/trading';
export * from './messages/outgoing/inventory/unseen';
export * from './messages/outgoing/landingview';
@@ -165,6 +168,7 @@ export * from './messages/parser/camera';
export * from './messages/parser/campaign';
export * from './messages/parser/catalog';
export * from './messages/parser/client';
export * from './messages/parser/commands';
export * from './messages/parser/competition';
export * from './messages/parser/crafting';
export * from './messages/parser/desktop';
@@ -187,6 +191,7 @@ export * from './messages/parser/inventory/badges';
export * from './messages/parser/inventory/clothing';
export * from './messages/parser/inventory/furniture';
export * from './messages/parser/inventory/pets';
export * from './messages/parser/inventory/prefixes';
export * from './messages/parser/inventory/purse';
export * from './messages/parser/inventory/trading';
export * from './messages/parser/landingview';
@@ -7,6 +7,7 @@ export class IncomingHeader
public static ACHIEVEMENT_LIST = 305;
public static AUTHENTICATED = 2491;
public static AUTHENTICATION = -1;
public static AVAILABLE_COMMANDS = 4050;
public static AVAILABILITY_STATUS = 2033;
public static BUILDERS_CLUB_EXPIRED = 1452;
public static CLUB_OFFERS = 2405;
@@ -474,4 +475,9 @@ export class IncomingHeader
public static WEEKLY_GAME2_LEADERBOARD = 2196;
public static RENTABLE_FURNI_RENT_OR_BUYOUT_OFFER = 35;
public static HANDSHAKE_IDENTITY_ACCOUNT = 3523;
// Custom Prefixes
public static USER_PREFIXES = 7001;
public static PREFIX_RECEIVED = 7002;
public static ACTIVE_PREFIX_UPDATED = 7003;
}
@@ -0,0 +1,16 @@
import { IMessageEvent } from '@nitrots/api';
import { MessageEvent } from '@nitrots/events';
import { AvailableCommandsParser } from '../../parser';
export class AvailableCommandsEvent extends MessageEvent implements IMessageEvent
{
constructor(callBack: Function)
{
super(callBack, AvailableCommandsParser);
}
public getParser(): AvailableCommandsParser
{
return this.parser as AvailableCommandsParser;
}
}
@@ -0,0 +1 @@
export * from './AvailableCommandsEvent';
@@ -8,6 +8,7 @@ export * from './camera';
export * from './campaign';
export * from './catalog';
export * from './client';
export * from './commands';
export * from './competition';
export * from './crafting';
export * from './desktop';
@@ -5,4 +5,5 @@ export * from './clothes';
export * from './furni';
export * from './furni/gifts';
export * from './pets';
export * from './prefixes';
export * from './trading';
@@ -0,0 +1,16 @@
import { IMessageEvent } from '@nitrots/api';
import { MessageEvent } from '@nitrots/events';
import { ActivePrefixUpdatedParser } from '../../../parser';
export class ActivePrefixUpdatedEvent extends MessageEvent implements IMessageEvent
{
constructor(callBack: Function)
{
super(callBack, ActivePrefixUpdatedParser);
}
public getParser(): ActivePrefixUpdatedParser
{
return this.parser as ActivePrefixUpdatedParser;
}
}
@@ -0,0 +1,16 @@
import { IMessageEvent } from '@nitrots/api';
import { MessageEvent } from '@nitrots/events';
import { PrefixReceivedParser } from '../../../parser';
export class PrefixReceivedEvent extends MessageEvent implements IMessageEvent
{
constructor(callBack: Function)
{
super(callBack, PrefixReceivedParser);
}
public getParser(): PrefixReceivedParser
{
return this.parser as PrefixReceivedParser;
}
}
@@ -0,0 +1,16 @@
import { IMessageEvent } from '@nitrots/api';
import { MessageEvent } from '@nitrots/events';
import { UserPrefixesParser } from '../../../parser';
export class UserPrefixesEvent extends MessageEvent implements IMessageEvent
{
constructor(callBack: Function)
{
super(callBack, UserPrefixesParser);
}
public getParser(): UserPrefixesParser
{
return this.parser as UserPrefixesParser;
}
}
@@ -0,0 +1,3 @@
export * from './ActivePrefixUpdatedEvent';
export * from './PrefixReceivedEvent';
export * from './UserPrefixesEvent';
@@ -478,4 +478,10 @@ export class OutgoingHeader
public static DELETE_ITEM = 10018;
public static DELETE_PET = 10030;
public static DELETE_BADGE = 10031;
// Custom Prefixes
public static REQUEST_PREFIXES = 7011;
public static SET_ACTIVE_PREFIX = 7012;
public static DELETE_PREFIX = 7013;
public static PURCHASE_PREFIX = 7014;
}
@@ -3,5 +3,6 @@ export * from './badges';
export * from './bots';
export * from './furni';
export * from './pets';
export * from './prefixes';
export * from './trading';
export * from './unseen';
@@ -0,0 +1,21 @@
import { IMessageComposer } from '@nitrots/api';
export class DeletePrefixComposer implements IMessageComposer<ConstructorParameters<typeof DeletePrefixComposer>>
{
private _data: ConstructorParameters<typeof DeletePrefixComposer>;
constructor(prefixId: number)
{
this._data = [ prefixId ];
}
public getMessageArray()
{
return this._data;
}
public dispose(): void
{
return;
}
}
@@ -0,0 +1,21 @@
import { IMessageComposer } from '@nitrots/api';
export class PurchasePrefixComposer implements IMessageComposer<ConstructorParameters<typeof PurchasePrefixComposer>>
{
private _data: ConstructorParameters<typeof PurchasePrefixComposer>;
constructor(text: string, color: string, icon: string = '', effect: string = '')
{
this._data = [ text, color, icon, effect ];
}
public getMessageArray()
{
return this._data;
}
public dispose(): void
{
return;
}
}
@@ -0,0 +1,21 @@
import { IMessageComposer } from '@nitrots/api';
export class RequestPrefixesComposer implements IMessageComposer<ConstructorParameters<typeof RequestPrefixesComposer>>
{
private _data: ConstructorParameters<typeof RequestPrefixesComposer>;
constructor()
{
this._data = [];
}
public getMessageArray()
{
return this._data;
}
public dispose(): void
{
return;
}
}
@@ -0,0 +1,21 @@
import { IMessageComposer } from '@nitrots/api';
export class SetActivePrefixComposer implements IMessageComposer<ConstructorParameters<typeof SetActivePrefixComposer>>
{
private _data: ConstructorParameters<typeof SetActivePrefixComposer>;
constructor(prefixId: number)
{
this._data = [ prefixId ];
}
public getMessageArray()
{
return this._data;
}
public dispose(): void
{
return;
}
}
@@ -0,0 +1,4 @@
export * from './DeletePrefixComposer';
export * from './PurchasePrefixComposer';
export * from './RequestPrefixesComposer';
export * from './SetActivePrefixComposer';
@@ -0,0 +1,37 @@
import { IMessageDataWrapper, IMessageParser } from '@nitrots/api';
export class AvailableCommandsParser implements IMessageParser
{
private _commands: { key: string; description: string }[];
public flush(): boolean
{
this._commands = [];
return true;
}
public parse(wrapper: IMessageDataWrapper): boolean
{
if(!wrapper) return false;
this._commands = [];
const count = wrapper.readInt();
for(let i = 0; i < count; i++)
{
this._commands.push({
key: wrapper.readString(),
description: wrapper.readString()
});
}
return true;
}
public get commands(): { key: string; description: string }[]
{
return this._commands;
}
}
@@ -0,0 +1 @@
export * from './AvailableCommandsParser';
@@ -7,6 +7,7 @@ export * from './camera';
export * from './campaign';
export * from './catalog';
export * from './client';
export * from './commands';
export * from './competition';
export * from './crafting';
export * from './desktop';
@@ -4,5 +4,6 @@ export * from './badges';
export * from './clothing';
export * from './furniture';
export * from './pets';
export * from './prefixes';
export * from './purse';
export * from './trading';
@@ -0,0 +1,39 @@
import { IMessageDataWrapper, IMessageParser } from '@nitrots/api';
export class ActivePrefixUpdatedParser implements IMessageParser
{
private _prefixId: number;
private _text: string;
private _color: string;
private _icon: string;
private _effect: string;
public flush(): boolean
{
this._prefixId = 0;
this._text = '';
this._color = '';
this._icon = '';
this._effect = '';
return true;
}
public parse(wrapper: IMessageDataWrapper): boolean
{
if(!wrapper) return false;
this._prefixId = wrapper.readInt();
this._text = wrapper.readString();
this._color = wrapper.readString();
this._icon = wrapper.readString();
this._effect = wrapper.readString();
return true;
}
public get prefixId(): number { return this._prefixId; }
public get text(): string { return this._text; }
public get color(): string { return this._color; }
public get icon(): string { return this._icon; }
public get effect(): string { return this._effect; }
}
@@ -0,0 +1,39 @@
import { IMessageDataWrapper, IMessageParser } from '@nitrots/api';
export class PrefixReceivedParser implements IMessageParser
{
private _id: number;
private _text: string;
private _color: string;
private _icon: string;
private _effect: string;
public flush(): boolean
{
this._id = 0;
this._text = '';
this._color = '';
this._icon = '';
this._effect = '';
return true;
}
public parse(wrapper: IMessageDataWrapper): boolean
{
if(!wrapper) return false;
this._id = wrapper.readInt();
this._text = wrapper.readString();
this._color = wrapper.readString();
this._icon = wrapper.readString();
this._effect = wrapper.readString();
return true;
}
public get id(): number { return this._id; }
public get text(): string { return this._text; }
public get color(): string { return this._color; }
public get icon(): string { return this._icon; }
public get effect(): string { return this._effect; }
}
@@ -0,0 +1,52 @@
import { IMessageDataWrapper, IMessageParser } from '@nitrots/api';
export interface IPrefixData
{
id: number;
text: string;
color: string;
icon: string;
effect: string;
active: boolean;
}
export class UserPrefixesParser implements IMessageParser
{
private _prefixes: IPrefixData[];
public flush(): boolean
{
this._prefixes = [];
return true;
}
public parse(wrapper: IMessageDataWrapper): boolean
{
if(!wrapper) return false;
this._prefixes = [];
let count = wrapper.readInt();
while(count > 0)
{
this._prefixes.push({
id: wrapper.readInt(),
text: wrapper.readString(),
color: wrapper.readString(),
icon: wrapper.readString(),
effect: wrapper.readString(),
active: wrapper.readInt() === 1
});
count--;
}
return true;
}
public get prefixes(): IPrefixData[]
{
return this._prefixes;
}
}
@@ -0,0 +1,3 @@
export * from './ActivePrefixUpdatedParser';
export * from './PrefixReceivedParser';
export * from './UserPrefixesParser';
@@ -9,6 +9,10 @@ export class RoomUnitChatParser implements IMessageParser
private _urls: string[];
private _chatColours: string;
private _messageLength: number;
private _prefixText: string;
private _prefixColor: string;
private _prefixIcon: string;
private _prefixEffect: string;
public flush(): boolean
{
@@ -19,6 +23,10 @@ export class RoomUnitChatParser implements IMessageParser
this._urls = [];
this._chatColours = null;
this._messageLength = 0;
this._prefixText = '';
this._prefixColor = '';
this._prefixIcon = '';
this._prefixEffect = '';
return true;
}
@@ -33,9 +41,13 @@ export class RoomUnitChatParser implements IMessageParser
this._bubble = wrapper.readInt();
this.parseUrls(wrapper);
this._chatColours = wrapper.readString();
this._messageLength = wrapper.readInt();
this._prefixText = wrapper.readString();
this._prefixColor = wrapper.readString();
this._prefixIcon = wrapper.readString();
this._prefixEffect = wrapper.readString();
return true;
}
@@ -82,7 +94,7 @@ export class RoomUnitChatParser implements IMessageParser
{
return this._urls;
}
public get chatColours(): string
{
return this._chatColours;
@@ -92,4 +104,24 @@ export class RoomUnitChatParser implements IMessageParser
{
return this._messageLength;
}
public get prefixText(): string
{
return this._prefixText;
}
public get prefixColor(): string
{
return this._prefixColor;
}
public get prefixIcon(): string
{
return this._prefixIcon;
}
public get prefixEffect(): string
{
return this._prefixEffect;
}
}
@@ -22,27 +22,52 @@ export class ConfigurationManager implements IConfigurationManager
try
{
this.resetConfiguration();
this.parseConfiguration(this.getDefaultConfig(), true);
const defaultConfig = this.getDefaultConfig();
if(!defaultConfig) throw new Error('Missing NitroConfig: make sure window.NitroConfig is defined in index.html');
this.parseConfiguration(defaultConfig, true);
const configurationUrls = this.getValue<string[]>('config.urls').slice();
if(!configurationUrls || !configurationUrls.length) throw new Error('Invalid configuration urls');
if(!configurationUrls || !configurationUrls.length) throw new Error('No config.urls defined in NitroConfig — expected an array like ["/renderer-config.json", "/ui-config.json"]');
for(const url of configurationUrls)
{
if(!url || !url.length) return;
const response = await fetch(url);
let response: Response;
if(response.status !== 200) throw new Error('Invalid configuration file');
try
{
response = await fetch(url);
}
catch(fetchError)
{
throw new Error(`Failed to fetch config "${ url }" — check that the file exists and the server is reachable (${ fetchError.message })`);
}
this.parseConfiguration(await response.json());
if(response.status !== 200) throw new Error(`Failed to load config "${ url }" — server returned HTTP ${ response.status }`);
let json: any;
try
{
json = await response.json();
}
catch(parseError)
{
throw new Error(`Invalid JSON in config "${ url }" — check for syntax errors like trailing commas or missing quotes (${ parseError.message })`);
}
this.parseConfiguration(json);
}
}
catch (err)
{
throw new Error(err);
throw new Error(err.message || String(err));
}
}
+7
View File
@@ -52,6 +52,13 @@ export class EventDispatcher implements IEventDispatcher
{
if(!event) return false;
// Debug: log SOCKET_ events to trace reconnection flow
if(event.type && event.type.startsWith('SOCKET_'))
{
const listenerCount = this._listeners.get(event.type)?.length ?? 0;
console.log('[EventDispatcher] Dispatching ' + event.type + ' (listeners: ' + listenerCount + ')');
}
NitroLogger.events('Dispatched Event', event.type);
this.processEvent(event);
+4
View File
@@ -8,6 +8,10 @@ export class NitroEventType
public static readonly SOCKET_CLOSED = 'SOCKET_CLOSED';
public static readonly SOCKET_ERROR = 'SOCKET_ERROR';
public static readonly SOCKET_CONNECTED = 'SOCKET_CONNECTED';
public static readonly SOCKET_RECONNECTING = 'SOCKET_RECONNECTING';
public static readonly SOCKET_RECONNECTED = 'SOCKET_RECONNECTED';
public static readonly SOCKET_RECONNECT_FAILED = 'SOCKET_RECONNECT_FAILED';
public static readonly SOCKET_REAUTHENTICATED = 'SOCKET_REAUTHENTICATED';
public static readonly AVATAR_ASSET_DOWNLOADED = 'AVATAR_ASSET_DOWNLOADED';
public static readonly AVATAR_ASSET_LOADED = 'AVATAR_ASSET_LOADED';
public static readonly AVATAR_EFFECT_DOWNLOADED = 'AVATAR_EFFECT_DOWNLOADED';
@@ -0,0 +1,25 @@
import { NitroEvent } from './NitroEvent';
export class ReconnectEvent extends NitroEvent
{
private _attempt: number;
private _maxAttempts: number;
constructor(type: string, attempt: number = 0, maxAttempts: number = 0)
{
super(type);
this._attempt = attempt;
this._maxAttempts = maxAttempts;
}
public get attempt(): number
{
return this._attempt;
}
public get maxAttempts(): number
{
return this._maxAttempts;
}
}
+1
View File
@@ -1,4 +1,5 @@
export * from './ConfigurationEvent';
export * from './MessageEvent';
export * from './NitroEvent';
export * from './ReconnectEvent';
export * from './SocketConnectionEvent';
@@ -24,8 +24,12 @@ export class RoomSessionChatEvent extends RoomSessionEvent
private _links: string[];
private _extraParam: number;
private _style: number;
private _prefixText: string;
private _prefixColor: string;
private _prefixIcon: string;
private _prefixEffect: string;
constructor(type: string, session: IRoomSession, objectId: number, message: string, chatType: number, style: number = 0, chatColours: string[], links: string[] = null, extraParam: number = -1)
constructor(type: string, session: IRoomSession, objectId: number, message: string, chatType: number, style: number = 0, chatColours: string[], links: string[] = null, extraParam: number = -1, prefixText: string = '', prefixColor: string = '', prefixIcon: string = '', prefixEffect: string = '')
{
super(type, session);
@@ -36,6 +40,10 @@ export class RoomSessionChatEvent extends RoomSessionEvent
this._links = links;
this._extraParam = extraParam;
this._style = style;
this._prefixText = prefixText;
this._prefixColor = prefixColor;
this._prefixIcon = prefixIcon;
this._prefixEffect = prefixEffect;
}
public get objectId(): number
@@ -67,9 +75,29 @@ export class RoomSessionChatEvent extends RoomSessionEvent
{
return this._style;
}
public get chatColours(): string[]
{
return this._chatColours;
}
public get prefixText(): string
{
return this._prefixText;
}
public get prefixColor(): string
{
return this._prefixColor;
}
public get prefixIcon(): string
{
return this._prefixIcon;
}
public get prefixEffect(): string
{
return this._prefixEffect;
}
}
@@ -16,7 +16,7 @@ export class LocalizationManager implements ILocalizationManager
{
const urls = GetConfiguration().getValue<string[]>('external.texts.url').slice();
if(!urls || !urls.length) throw new Error('Invalid localization urls');
if(!urls || !urls.length) throw new Error('Missing "external.texts.url" in config — add the localization URL to your ui-config.json');
for(let url of urls)
{
@@ -24,11 +24,31 @@ export class LocalizationManager implements ILocalizationManager
url = GetConfiguration().interpolate(url);
const response = await fetch(url);
let response: Response;
if(response.status !== 200) throw new Error('Invalid localization file');
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 })`);
}
this.parseLocalization(await response.json());
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();
}
catch(parseErr)
{
throw new Error(`Invalid JSON in localization file "${ url }" — the URL may be wrong. Check "external.texts.url" in ui-config.json (${ parseErr.message })`);
}
this.parseLocalization(data);
}
GetCommunication().registerMessageEvent(new BadgePointLimitsEvent(this.onBadgePointLimitsEvent.bind(this)));
@@ -36,7 +56,7 @@ export class LocalizationManager implements ILocalizationManager
catch (err)
{
throw new Error(err);
throw new Error(err.message || String(err));
}
}
+13
View File
@@ -17,6 +17,7 @@ export class RoomMessageHandler
private _currentRoomId: number = 0;
private _ownUserId: number = 0;
private _ownRoomIndex: number = -1;
private _initialConnection: boolean = true;
private _guideId: number = -1;
private _requesterId: number = -1;
@@ -635,6 +636,7 @@ export class RoomMessageHandler
if(user.webID === this._ownUserId)
{
this._ownRoomIndex = user.roomIndex;
this._roomEngine.setRoomSessionOwnUser(this._currentRoomId, user.roomIndex);
this._roomEngine.updateRoomObjectUserOwn(this._currentRoomId, user.roomIndex);
}
@@ -735,6 +737,17 @@ export class RoomMessageHandler
this._roomEngine.updateRoomObjectUserLocation(this._currentRoomId, status.id, location, goal, status.canStandUp, height, direction, status.headDirection);
this._roomEngine.updateRoomObjectUserFlatControl(this._currentRoomId, status.id, null);
// Save own user's position for reconnection
if(status.id === this._ownRoomIndex)
{
try
{
sessionStorage.setItem('nitro.session.lastPosX', status.x.toString());
sessionStorage.setItem('nitro.session.lastPosY', status.y.toString());
}
catch(e) { /* ignore */ }
}
let isPosture = true;
let postureUpdate = false;
let postureType = RoomObjectVariable.STD;
@@ -25,6 +25,8 @@ export class FloorplanEditor
private _image: HTMLImageElement;
public onTilemapChange: (() => void) | null = null;
constructor()
{
const width = TILE_SIZE * MAX_NUM_TILE_PER_AXIS + 20;
@@ -297,6 +299,8 @@ export class FloorplanEditor
}
this.renderSquareSelectionPreview();
if(this.onTilemapChange) this.onTilemapChange();
}
private renderSquareSelectionPreview(): void
@@ -473,6 +477,7 @@ export class FloorplanEditor
this._squareSelectStart = null;
this._squareSelectEnd = null;
this.clearCanvas();
this.onTilemapChange = null;
}
+23 -1
View File
@@ -9,6 +9,8 @@ export class RoomSession implements IRoomSession
private _roomId: number = 0;
private _password: string = null;
private _spawnX: number = -1;
private _spawnY: number = -1;
private _state: string = RoomSessionEvent.CREATED;
private _tradeMode: number = RoomTradingLevelEnum.NO_TRADING;
private _doorMode: number = 0;
@@ -57,7 +59,7 @@ export class RoomSession implements IRoomSession
{
if(!GetCommunication().connection) return false;
GetCommunication().connection.send(new RoomEnterComposer(this._roomId, this._password));
GetCommunication().connection.send(new RoomEnterComposer(this._roomId, this._password, this._spawnX, this._spawnY));
return true;
}
@@ -326,6 +328,26 @@ export class RoomSession implements IRoomSession
this._password = password;
}
public get spawnX(): number
{
return this._spawnX;
}
public set spawnX(x: number)
{
this._spawnX = x;
}
public get spawnY(): number
{
return this._spawnY;
}
public set spawnY(y: number)
{
this._spawnY = y;
}
public get state(): string
{
return this._state;
+480 -4
View File
@@ -1,9 +1,15 @@
import { IRoomHandlerListener, IRoomSession, IRoomSessionManager } from '@nitrots/api';
import { GetCommunication } from '@nitrots/communication';
import { GetEventDispatcher, RoomSessionEvent } from '@nitrots/events';
import { GetCommunication, RoomEnterComposer, RoomUnitWalkComposer } from '@nitrots/communication';
import { GetEventDispatcher, 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';
const STORAGE_KEY_ROOM_ID = 'nitro.session.lastRoomId';
const STORAGE_KEY_ROOM_PASSWORD = 'nitro.session.lastRoomPassword';
const STORAGE_KEY_POS_X = 'nitro.session.lastPosX';
const STORAGE_KEY_POS_Y = 'nitro.session.lastPosY';
export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerListener
{
private _handlers: BaseHandler[] = [];
@@ -13,10 +19,52 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
private _sessionStarting: boolean = false;
private _viewerSession: IRoomSession = null;
// Reconnection state tracking
private _lastRoomId: number = -1;
private _lastRoomPassword: string = null;
private _isReconnecting: boolean = false;
private _reconnectGuardTimer: ReturnType<typeof setTimeout> = null;
private _pendingRoomClear: ReturnType<typeof setTimeout> = null;
private _savedPosX: number = -1;
private _savedPosY: number = -1;
public async init(): Promise<void>
{
console.log('[RoomSessionManager] init() called');
this.createHandlers();
this.processPendingSession();
this.setupReconnectListener();
// Check if there's a persisted room from a network disconnect (Vite page reload).
// sessionStorage survives same-tab reloads but is cleared on browser close.
this.checkPersistedRoom();
}
private checkPersistedRoom(): void
{
try
{
const storedRoomId = sessionStorage.getItem(STORAGE_KEY_ROOM_ID);
if(!storedRoomId) return;
const roomId = parseInt(storedRoomId, 10);
if(isNaN(roomId) || roomId <= 0) return;
NitroLogger.log('[RoomSessionManager] Found persisted room ' + roomId + ' - setting guard for page-reload restore');
// Pre-load memory state so attemptRoomReEntry and tryRestoreSession can use it
this._lastRoomId = roomId;
this._lastRoomPassword = sessionStorage.getItem(STORAGE_KEY_ROOM_PASSWORD) || null;
// Enable guard to block DesktopViewEvent until we enter the stored room
this._isReconnecting = true;
}
catch(e)
{
// sessionStorage not available
}
}
private createHandlers(): void
@@ -40,6 +88,341 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
);
}
private setupReconnectListener(): void
{
console.log('[RoomSessionManager] setupReconnectListener() - registering event listeners');
// Mark reconnecting state early so DesktopViewEvent / home room redirects
// don't clear the tracked room before we can re-enter it
GetEventDispatcher().addEventListener(NitroEventType.SOCKET_RECONNECTING, () =>
{
// Cancel any pending room ID clear from removeSession().
// The server sends DesktopViewEvent before closing the socket, which
// schedules a delayed clear. We need to preserve the room ID for re-entry.
this.cancelRoomIdClear();
// Re-persist room to sessionStorage (it was cleared in removeSession)
if(this._lastRoomId > 0)
{
this.persistRoom(this._lastRoomId, this._lastRoomPassword);
}
console.log('[RoomSessionManager] SOCKET_RECONNECTING fired! lastRoomId=' + this._lastRoomId);
this._isReconnecting = true;
});
// SOCKET_RECONNECTED: the WebSocket is open but NOT yet authenticated.
// We set up a fallback timer here in case the server doesn't send
// AuthenticatedEvent (e.g. SSO ticket consumed).
GetEventDispatcher().addEventListener(NitroEventType.SOCKET_RECONNECTED, () =>
{
console.log('[RoomSessionManager] SOCKET_RECONNECTED fired! lastRoomId=' + this._lastRoomId);
// Fallback: if REAUTHENTICATED doesn't fire within 5 seconds,
// try to re-enter the room anyway (the connection might still work)
this.clearGuardTimer();
this._reconnectGuardTimer = setTimeout(() =>
{
this._reconnectGuardTimer = null;
if(!this._isReconnecting) return;
NitroLogger.log('[RoomSessionManager] REAUTHENTICATED timeout - attempting fallback room re-entry for room ' + this._lastRoomId);
this.attemptRoomReEntry();
}, 5000);
});
// REAUTHENTICATED: SSO handshake completed, connection is ready to send messages
GetEventDispatcher().addEventListener(NitroEventType.SOCKET_REAUTHENTICATED, () =>
{
console.log('[RoomSessionManager] SOCKET_REAUTHENTICATED fired! lastRoomId=' + this._lastRoomId);
// Snapshot the saved position BEFORE re-entering (re-entry overwrites sessionStorage)
this.snapshotSavedPosition();
this.clearGuardTimer();
this.attemptRoomReEntry();
});
GetEventDispatcher().addEventListener(NitroEventType.SOCKET_RECONNECT_FAILED, () =>
{
NitroLogger.log('[RoomSessionManager] SOCKET_RECONNECT_FAILED - clearing state');
this.clearGuardTimer();
this._isReconnecting = false;
this._lastRoomId = -1;
this._lastRoomPassword = null;
this.clearPersistedRoom();
this.clearPersistedPosition();
});
// When the socket is permanently closed (server shutdown, max retries),
// clear the persisted room so the next page load uses normal navigation
GetEventDispatcher().addEventListener(NitroEventType.SOCKET_CLOSED, () =>
{
NitroLogger.log('[RoomSessionManager] SOCKET_CLOSED - clearing persisted room');
this.clearGuardTimer();
this._isReconnecting = false;
this._lastRoomId = -1;
this._lastRoomPassword = null;
this.clearPersistedRoom();
this.clearPersistedPosition();
});
}
private clearGuardTimer(): void
{
if(this._reconnectGuardTimer)
{
clearTimeout(this._reconnectGuardTimer);
this._reconnectGuardTimer = null;
}
}
private scheduleRoomIdClear(): void
{
if(this._pendingRoomClear)
{
clearTimeout(this._pendingRoomClear);
}
this._pendingRoomClear = setTimeout(() =>
{
this._pendingRoomClear = null;
this._lastRoomId = -1;
this._lastRoomPassword = null;
}, 5000);
}
private cancelRoomIdClear(): void
{
if(this._pendingRoomClear)
{
clearTimeout(this._pendingRoomClear);
this._pendingRoomClear = null;
}
}
private attemptRoomReEntry(): void
{
const roomId = this._lastRoomId;
const password = this._lastRoomPassword;
if(roomId <= 0)
{
NitroLogger.log('[RoomSessionManager] No room to re-enter (lastRoomId=' + roomId + '), dropping guard');
this._isReconnecting = false;
return;
}
// Check if we already have a session for this room (seamless reconnection).
// The server-side SessionResumeManager kept the habbo alive in the room
// during the grace period. The client's room view is still rendered behind
// the reconnection overlay. Instead of tearing it down and rebuilding,
// just drop the guard so the room view "unfreezes" in place.
const existingSession = this.getSession(roomId);
if(existingSession)
{
NitroLogger.log('[RoomSessionManager] Existing session found for room ' + roomId + ' — sending room enter request');
// Re-send room enter request to the server with saved spawn coordinates.
// The server will place the habbo directly at the saved position
// instead of the door tile, providing a seamless reconnection experience.
GetCommunication().connection.send(new RoomEnterComposer(roomId, password, this._savedPosX, this._savedPosY));
// Keep the guard up briefly to absorb any stray server-side redirects
// (DesktopViewEvent, etc.) from the login packet sequence, then drop it.
this.clearGuardTimer();
this._reconnectGuardTimer = setTimeout(() =>
{
this._reconnectGuardTimer = null;
if(this._isReconnecting)
{
NitroLogger.log('[RoomSessionManager] Session resume guard timeout - dropping guard');
this._isReconnecting = false;
}
}, 5000);
return;
}
NitroLogger.log('[RoomSessionManager] Re-entering room ' + roomId);
// No existing session — full room entry (e.g. page reload restore)
this._sessions.clear();
this._viewerSession = null;
// Send the room enter request with saved spawn coordinates. The server
// will place the habbo at the saved position instead of the door tile.
this.createSession(roomId, password, this._savedPosX, this._savedPosY);
// Keep the guard up for a generous window to absorb any DesktopViewEvent
// or other server-side redirects that arrive after authentication.
// The guard drops when:
// 1. RS_CONNECTED/RS_READY fires (positive room entry confirmation), OR
// 2. This safety timeout expires (10 seconds)
this.clearGuardTimer();
this._reconnectGuardTimer = setTimeout(() =>
{
this._reconnectGuardTimer = null;
if(this._isReconnecting)
{
NitroLogger.log('[RoomSessionManager] Guard timeout (10s) - dropping guard');
this._isReconnecting = false;
}
}, 10000);
}
/**
* Called on page load (from MainView). Checks sessionStorage for a
* persisted room ID from a network disconnect and enters it instead
* of following the normal home room / hotel view flow.
*
* Returns true if a room restore was initiated.
*/
public tryRestoreSession(): boolean
{
try
{
const storedRoomId = sessionStorage.getItem(STORAGE_KEY_ROOM_ID);
if(!storedRoomId) return false;
const roomId = parseInt(storedRoomId, 10);
if(isNaN(roomId) || roomId <= 0) return false;
const password = sessionStorage.getItem(STORAGE_KEY_ROOM_PASSWORD) || null;
// Read saved position for page-reload restore
let spawnX = -1;
let spawnY = -1;
try
{
const posX = sessionStorage.getItem(STORAGE_KEY_POS_X);
const posY = sessionStorage.getItem(STORAGE_KEY_POS_Y);
if(posX && posY)
{
spawnX = parseInt(posX, 10);
spawnY = parseInt(posY, 10);
if(isNaN(spawnX) || isNaN(spawnY)) { spawnX = -1; spawnY = -1; }
}
}
catch(e) { /* ignore */ }
NitroLogger.log('[RoomSessionManager] Restoring session for room ' + roomId + ' from sessionStorage (spawn: ' + spawnX + ', ' + spawnY + ')');
// Set the guard so DesktopViewEvent from the server's login sequence
// doesn't kick us to hotel view before we enter the room
this._isReconnecting = true;
this.createSession(roomId, password, spawnX, spawnY);
// Drop the guard when room entry succeeds or after timeout
this.clearGuardTimer();
this._reconnectGuardTimer = setTimeout(() =>
{
this._reconnectGuardTimer = null;
if(this._isReconnecting)
{
NitroLogger.log('[RoomSessionManager] Restore guard timeout (10s) - dropping guard');
this._isReconnecting = false;
}
}, 10000);
return true;
}
catch(e)
{
return false;
}
}
private persistRoom(roomId: number, password: string): void
{
try
{
if(roomId > 0)
{
sessionStorage.setItem(STORAGE_KEY_ROOM_ID, roomId.toString());
if(password)
{
sessionStorage.setItem(STORAGE_KEY_ROOM_PASSWORD, password);
}
else
{
sessionStorage.removeItem(STORAGE_KEY_ROOM_PASSWORD);
}
}
else
{
this.clearPersistedRoom();
}
}
catch(e)
{
// sessionStorage not available (private browsing, etc.) - fail silently
}
}
private clearPersistedRoom(): void
{
try
{
sessionStorage.removeItem(STORAGE_KEY_ROOM_ID);
sessionStorage.removeItem(STORAGE_KEY_ROOM_PASSWORD);
// Note: position keys (POS_X, POS_Y) are NOT cleared here.
// They persist across the disconnect→reconnect cycle and are
// sent to the server as spawn coordinates during re-entry.
}
catch(e)
{
// ignore
}
}
private clearPersistedPosition(): void
{
try
{
sessionStorage.removeItem(STORAGE_KEY_POS_X);
sessionStorage.removeItem(STORAGE_KEY_POS_Y);
}
catch(e)
{
// ignore
}
}
private snapshotSavedPosition(): void
{
try
{
const posX = sessionStorage.getItem(STORAGE_KEY_POS_X);
const posY = sessionStorage.getItem(STORAGE_KEY_POS_Y);
if(!posX || !posY) return;
this._savedPosX = parseInt(posX, 10);
this._savedPosY = parseInt(posY, 10);
NitroLogger.log('[RoomSessionManager] Snapshot saved position (' + this._savedPosX + ', ' + this._savedPosY + ')');
}
catch(e)
{
this._savedPosX = -1;
this._savedPosY = -1;
}
}
private setHandlers(session: IRoomSession): void
{
if(!this._handlers || !this._handlers.length) return;
@@ -70,12 +453,14 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
return existing;
}
public createSession(roomId: number, password: string = null): boolean
public createSession(roomId: number, password: string = null, spawnX: number = -1, spawnY: number = -1): boolean
{
const session = new RoomSession();
session.roomId = roomId;
session.password = password;
session.spawnX = spawnX;
session.spawnY = spawnY;
return this.addSession(session);
}
@@ -92,6 +477,11 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
this._viewerSession = roomSession;
// Track room for reconnection (memory + sessionStorage)
this._lastRoomId = roomSession.roomId;
this._lastRoomPassword = roomSession.password;
this.persistRoom(roomSession.roomId, roomSession.password);
this.startSession(this._viewerSession);
return true;
@@ -123,8 +513,29 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
if(!session) return;
// During reconnection, block BOTH the session map deletion AND the ENDED event.
// This preserves the session so attemptRoomReEntry can find it, and prevents
// the UI from flashing hotel view during the reconnection flow.
if(this._isReconnecting)
{
NitroLogger.log('[RoomSessionManager] removeSession fully blocked by reconnect guard (room=' + id + ', openLandingView=' + openLandingView + ')');
return;
}
this._sessions.delete(this.getRoomId(id));
if(openLandingView)
{
// Don't clear _lastRoomId immediately. During server shutdown the server
// sends DesktopViewEvent (which triggers removeSession) BEFORE closing the
// socket. If we clear the room ID now, the SOCKET_RECONNECTING handler
// won't know which room to re-enter. Instead, delay the clear so that
// SOCKET_RECONNECTING can cancel it and preserve the room info.
this.clearPersistedRoom();
this.scheduleRoomIdClear();
}
GetEventDispatcher().dispatchEvent(new RoomSessionEvent(RoomSessionEvent.ENDED, session, openLandingView));
}
@@ -132,15 +543,71 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
{
const session = this.getSession(id);
if(!session) return;
if(!session)
{
NitroLogger.log('[RoomSessionManager] sessionUpdate(' + type + ') - no session found for id ' + id);
return;
}
switch(type)
{
case RoomSessionHandler.RS_CONNECTED:
NitroLogger.log('[RoomSessionManager] RS_CONNECTED for room ' + id);
// Positive signal: we successfully entered the room.
// Do NOT drop the guard yet — the server's login sequence may still
// send a DesktopViewEvent that arrives after this. Keep the guard up
// and let RS_READY (or the existing timeout) handle the final drop.
if(this._isReconnecting)
{
NitroLogger.log('[RoomSessionManager] Room entry confirmed - guard stays up until RS_READY');
}
return;
case RoomSessionHandler.RS_READY:
NitroLogger.log('[RoomSessionManager] RS_READY for room ' + id);
// Room is fully loaded. Keep the guard up for a short grace period
// to absorb any late DesktopViewEvent from the server's login sequence,
// then drop it. This prevents a race where the login sequence's
// DesktopViewEvent arrives after the room entry confirmation.
if(this._isReconnecting)
{
NitroLogger.log('[RoomSessionManager] Room ready confirmed - dropping guard in 3s');
// If we have saved spawn coordinates, send a walk command so the
// avatar moves to their previous position. This handles the EMU-restart
// case where the server has no ghost session and spawns at the door.
if(this._savedPosX >= 0 && this._savedPosY >= 0)
{
NitroLogger.log('[RoomSessionManager] Walking to saved position (' + this._savedPosX + ', ' + this._savedPosY + ')');
GetCommunication().connection.send(new RoomUnitWalkComposer(this._savedPosX, this._savedPosY));
this._savedPosX = -1;
this._savedPosY = -1;
}
this.clearGuardTimer();
this._reconnectGuardTimer = setTimeout(() =>
{
this._reconnectGuardTimer = null;
if(this._isReconnecting)
{
NitroLogger.log('[RoomSessionManager] Post-ready grace period elapsed - dropping guard');
this._isReconnecting = false;
}
}, 3000);
}
return;
case RoomSessionHandler.RS_DISCONNECTED:
NitroLogger.log('[RoomSessionManager] RS_DISCONNECTED for room ' + id + ' (isReconnecting=' + this._isReconnecting + ')');
// During reconnection, don't process server-side disconnects
// (DesktopViewEvent / home room redirect) - we'll re-enter the room
if(this._isReconnecting) return;
this.removeSession(id);
return;
}
@@ -158,6 +625,10 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
this._sessions.set(this.getRoomId(toRoomId), existing);
// Update tracked room
this._lastRoomId = toRoomId;
this.persistRoom(toRoomId, existing.password);
this.setHandlers(existing);
}
@@ -170,4 +641,9 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList
{
return this._viewerSession;
}
public get isReconnecting(): boolean
{
return this._isReconnecting;
}
}
@@ -18,13 +18,31 @@ export class FurnitureDataLoader
{
const url = GetConfiguration().getValue<string>('furnidata.url');
if(!url || !url.length) throw new Error('invalid furni data url');
if(!url || !url.length) throw new Error('Missing "furnidata.url" in config — add the furniture data URL to your renderer-config.json');
const response = await fetch(url);
let response: Response;
if(response.status !== 200) throw new Error('Invalid furni data file');
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 })`);
}
const responseData = await response.json();
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();
}
catch(parseErr)
{
throw new Error(`Invalid JSON in furniture data "${ url }" — the URL may be wrong. Check "furnidata.url" in renderer-config.json (${ parseErr.message })`);
}
if(responseData.roomitemtypes) this.parseFloorItems(responseData.roomitemtypes);
@@ -37,7 +37,7 @@ export class RoomChatHandler extends BaseHandler
if(event instanceof RoomUnitChatShoutEvent) chatType = RoomSessionChatEvent.CHAT_TYPE_SHOUT;
else if(event instanceof RoomUnitChatWhisperEvent) chatType = RoomSessionChatEvent.CHAT_TYPE_WHISPER;
const chatEvent = new RoomSessionChatEvent(RoomSessionChatEvent.CHAT_EVENT, session, parser.roomIndex, parser.message, chatType, parser.bubble, parser.chatColours);
const chatEvent = new RoomSessionChatEvent(RoomSessionChatEvent.CHAT_EVENT, session, parser.roomIndex, parser.message, chatType, parser.bubble, parser.chatColours, null, -1, parser.prefixText, parser.prefixColor, parser.prefixIcon, parser.prefixEffect);
GetEventDispatcher().dispatchEvent(chatEvent);
}
@@ -1,6 +1,7 @@
import { IConnection, IRoomHandlerListener } from '@nitrots/api';
import { DesktopViewEvent, FlatAccessDeniedMessageEvent, GoToFlatMessageComposer, RoomDoorbellAcceptedEvent, RoomEnterEvent, RoomReadyMessageEvent, YouAreSpectatorMessageEvent } from '@nitrots/communication';
import { GetEventDispatcher, RoomSessionDoorbellEvent, RoomSessionSpectatorModeEvent } from '@nitrots/events';
import { NitroLogger } from '@nitrots/utils';
import { BaseHandler } from './BaseHandler';
export class RoomSessionHandler extends BaseHandler
@@ -46,6 +47,8 @@ export class RoomSessionHandler extends BaseHandler
{
if(!(event instanceof DesktopViewEvent)) return;
NitroLogger.log('[RoomSessionHandler] DesktopViewEvent received (roomId=' + this.roomId + ')');
if(this.listener) this.listener.sessionUpdate(this.roomId, RoomSessionHandler.RS_DISCONNECTED);
}
@@ -85,6 +88,7 @@ export class RoomSessionHandler extends BaseHandler
if(!username || !username.length)
{
NitroLogger.log('[RoomSessionHandler] FlatAccessDenied (empty username) → RS_DISCONNECTED (roomId=' + this.roomId + ')');
this.listener.sessionUpdate(this.roomId, RoomSessionHandler.RS_DISCONNECTED);
}
else
@@ -15,13 +15,31 @@ export class ProductDataLoader
{
const url = GetConfiguration().getValue<string>('productdata.url');
if(!url || !url.length) throw new Error('invalid product data url');
if(!url || !url.length) throw new Error('Missing "productdata.url" in config — add the product data URL to your renderer-config.json');
const response = await fetch(url);
let response: Response;
if(response.status !== 200) throw new Error('Invalid product data file');
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 })`);
}
const responseData = await response.json();
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();
}
catch(parseErr)
{
throw new Error(`Invalid JSON in product data "${ url }" — the URL may be wrong. Check "productdata.url" in renderer-config.json (${ parseErr.message })`);
}
this.parseProducts(responseData.productdata);
}
+8 -13
View File
@@ -1,30 +1,25 @@
export class NitroVersion
{
public static RENDERER_VERSION: string = '2.0.0';
public static UI_VERSION: string = '';
public static RENDERER_VERSION: string = '3.0.0';
public static UI_VERSION: string = '3.0.4';
public static sayHello(): void
{
if(navigator.userAgent.toLowerCase().indexOf('chrome') > -1)
{
const args = [
`\n %c %c %c Nitro ${NitroVersion.UI_VERSION} - Renderer ${NitroVersion.RENDERER_VERSION} %c %c %c https://discord.nitrodev.co %c %c \n\n`,
'background: #ffffff; padding:5px 0;',
'background: #ffffff; padding:5px 0;',
'color: #ffffff; background: #000000; padding:5px 0;',
'background: #ffffff; padding:5px 0;',
'background: #ffffff; padding:5px 0;',
'background: #000000; padding:5px 0;',
'background: #ffffff; padding:5px 0;',
'background: #ffffff; padding:5px 0;'
`\n %c NITRO %c UI ${NitroVersion.UI_VERSION} %c Renderer ${NitroVersion.RENDERER_VERSION} %c \n`,
'background: #1a3a5c; color: #ffffff; font-size: 14px; font-weight: bold; padding: 8px 12px; border-radius: 6px 0 0 6px;',
'background: #2a5f8f; color: #e0ecf8; font-size: 12px; padding: 8px 10px;',
'background: #3d7ab5; color: #e0ecf8; font-size: 12px; padding: 8px 10px; border-radius: 0 6px 6px 0;',
'background: transparent;'
];
self.console.log(...args);
}
else if(self.console)
{
self.console.log(`Nitro ${NitroVersion.UI_VERSION} - Renderer ${NitroVersion.RENDERER_VERSION} `);
self.console.log(`Nitro UI ${NitroVersion.UI_VERSION} - Renderer ${NitroVersion.RENDERER_VERSION}`);
}
}
}