You've already forked Nitro_Render_V3
mirror of
https://github.com/duckietm/Nitro_Render_V3.git
synced 2026-06-19 15:06:20 +00:00
Merge remote-tracking branch 'origin/main'
# Conflicts: # packages/communication/src/NitroMessages.ts
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
+16
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
@@ -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';
|
||||
|
||||
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
+21
@@ -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';
|
||||
|
||||
+39
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user