diff --git a/packages/events/src/EventDispatcher.ts b/packages/events/src/EventDispatcher.ts index 086383d..fd1ae2d 100644 --- a/packages/events/src/EventDispatcher.ts +++ b/packages/events/src/EventDispatcher.ts @@ -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); diff --git a/packages/room/src/RoomMessageHandler.ts b/packages/room/src/RoomMessageHandler.ts index fa0bd41..ed836f5 100644 --- a/packages/room/src/RoomMessageHandler.ts +++ b/packages/room/src/RoomMessageHandler.ts @@ -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,22 @@ 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 (only when not locked by reconnect flow) + if(status.id === this._ownRoomIndex) + { + try + { + const locked = sessionStorage.getItem('nitro.session.posLocked'); + + if(!locked) + { + 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; diff --git a/packages/session/src/RoomSessionManager.ts b/packages/session/src/RoomSessionManager.ts index 6584bbe..c8e979d 100644 --- a/packages/session/src/RoomSessionManager.ts +++ b/packages/session/src/RoomSessionManager.ts @@ -1,5 +1,5 @@ import { IRoomHandlerListener, IRoomSession, IRoomSessionManager } from '@nitrots/api'; -import { GetCommunication } from '@nitrots/communication'; +import { GetCommunication, RoomEnterComposer, RoomUnitWalkComposer } from '@nitrots/communication'; import { GetEventDispatcher, NitroEventType, RoomSessionEvent } from '@nitrots/events'; import { NitroLogger } from '@nitrots/utils'; import { RoomSession } from './RoomSession'; @@ -7,24 +7,36 @@ import { BaseHandler, GenericErrorHandler, PetPackageHandler, PollHandler, RoomC 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[] = []; private _sessions: Map = new Map(); private _pendingSession: IRoomSession = null; + 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 = null; + private _pendingRoomClear: ReturnType = null; + private _savedPosX: number = -1; + private _savedPosY: number = -1; public async init(): Promise { + 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(); } @@ -39,11 +51,20 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList 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) {} + catch(e) + { + // sessionStorage not available + } } private createHandlers(): void @@ -69,45 +90,82 @@ 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, () => { - this._isReconnecting = true; + // 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(); }); } @@ -120,6 +178,30 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList } } + 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; @@ -127,14 +209,62 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList 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. This handles two cases: + // 1. Session resume (habbo still in room on server): server treats it + // as a no-op or re-entry to the same room — harmless. + // 2. Server restart (habbo not in any room): server places the habbo + // in the room so the client view matches server state. + GetCommunication().connection.send(new RoomEnterComposer(roomId, password)); + + // 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. The guard stays active to block + // DesktopViewEvent / home room redirects from the server's login sequence. this.createSession(roomId, password); + + // 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(() => { @@ -142,11 +272,19 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList 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 @@ -161,10 +299,15 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList const password = sessionStorage.getItem(STORAGE_KEY_ROOM_PASSWORD) || null; + NitroLogger.log('[RoomSessionManager] Restoring session for room ' + roomId + ' from sessionStorage'); + + // 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); + // Drop the guard when room entry succeeds or after timeout this.clearGuardTimer(); this._reconnectGuardTimer = setTimeout(() => { @@ -172,6 +315,7 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList if(this._isReconnecting) { + NitroLogger.log('[RoomSessionManager] Restore guard timeout (10s) - dropping guard'); this._isReconnecting = false; } }, 10000); @@ -206,7 +350,10 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList this.clearPersistedRoom(); } } - catch(e) {} + catch(e) + { + // sessionStorage not available (private browsing, etc.) - fail silently + } } private clearPersistedRoom(): void @@ -215,8 +362,70 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList { 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 + // consumed by walkToSavedPosition() after successful re-entry. } - catch(e) {} + catch(e) + { + // ignore + } + } + + private clearPersistedPosition(): void + { + try + { + sessionStorage.removeItem(STORAGE_KEY_POS_X); + sessionStorage.removeItem(STORAGE_KEY_POS_Y); + sessionStorage.removeItem('nitro.session.posLocked'); + } + 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); + + // Lock position saving so room re-entry doesn't overwrite saved position + sessionStorage.setItem('nitro.session.posLocked', '1'); + + NitroLogger.log('[RoomSessionManager] Snapshot saved position (' + this._savedPosX + ', ' + this._savedPosY + ')'); + } + catch(e) + { + this._savedPosX = -1; + this._savedPosY = -1; + } + } + + private walkToSavedPosition(): void + { + const x = this._savedPosX; + const y = this._savedPosY; + + // Reset after use + this._savedPosX = -1; + this._savedPosY = -1; + + // Unlock position saving so normal movement is tracked again + try { sessionStorage.removeItem('nitro.session.posLocked'); } catch(e) { /* ignore */ } + + if(x < 0 || y < 0 || isNaN(x) || isNaN(y)) return; + + NitroLogger.log('[RoomSessionManager] Walking to saved position (' + x + ', ' + y + ')'); + GetCommunication().connection.send(new RoomUnitWalkComposer(x, y)); } private setHandlers(session: IRoomSession): void @@ -271,6 +480,7 @@ 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); @@ -306,20 +516,29 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList if(!session) return; - this._sessions.delete(this.getRoomId(id)); - - if(openLandingView && !this._isReconnecting) - { - this._lastRoomId = -1; - this._lastRoomPassword = null; - this.clearPersistedRoom(); - } - + // 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)); } @@ -329,30 +548,60 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList 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) { - this.clearGuardTimer(); - this._isReconnecting = false; + 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'); + + // Walk to the saved position (where the user was before disconnect). + // Delay briefly so the server finishes placing the avatar in the room. + setTimeout(() => this.walkToSavedPosition(), 1000); + this.clearGuardTimer(); - this._isReconnecting = false; + 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); @@ -372,6 +621,7 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList this._sessions.set(this.getRoomId(toRoomId), existing); + // Update tracked room this._lastRoomId = toRoomId; this.persistRoom(toRoomId, existing.password); @@ -387,4 +637,9 @@ export class RoomSessionManager implements IRoomSessionManager, IRoomHandlerList { return this._viewerSession; } + + public get isReconnecting(): boolean + { + return this._isReconnecting; + } } diff --git a/packages/session/src/handler/RoomSessionHandler.ts b/packages/session/src/handler/RoomSessionHandler.ts index 717c9e3..474ef88 100644 --- a/packages/session/src/handler/RoomSessionHandler.ts +++ b/packages/session/src/handler/RoomSessionHandler.ts @@ -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