diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java index 61fa6085..f2724578 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/gameclients/SessionResumeManager.java @@ -8,6 +8,16 @@ import org.slf4j.LoggerFactory; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledFuture; +/** + * Manages a grace period for disconnected users. Instead of immediately + * disposing a Habbo when their WebSocket drops, the Habbo is held in + * a "ghost" state for a configurable number of seconds. If the same + * user reconnects (via SSO ticket) within the grace window, their + * existing Habbo object is resumed on the new connection — keeping + * them in their room, preserving inventory state, etc. + * + * Config key: session.reconnect.grace.seconds (default: 30) + */ public class SessionResumeManager { private static final Logger LOGGER = LoggerFactory.getLogger(SessionResumeManager.class); @@ -27,6 +37,12 @@ public class SessionResumeManager { return Emulator.getConfig().getInt("session.reconnect.grace.seconds", 30); } + /** + * Park a disconnected Habbo in ghost mode. Their room presence is + * preserved, but the old GameClient channel is closed. + * + * @return true if the habbo was parked (grace period > 0), false if immediate dispose should happen + */ public boolean parkHabbo(Habbo habbo, String ssoTicket) { int graceSeconds = getGracePeriodSeconds(); if (graceSeconds <= 0) { @@ -35,6 +51,7 @@ public class SessionResumeManager { int userId = habbo.getHabboInfo().getId(); + // Cancel any existing ghost session for this user GhostSession existing = ghostSessions.remove(userId); if (existing != null && existing.disposeFuture != null) { existing.disposeFuture.cancel(false); @@ -43,10 +60,12 @@ public class SessionResumeManager { LOGGER.info("[SessionResume] Parking {} (id={}) for {}s grace period", habbo.getHabboInfo().getUsername(), userId, graceSeconds); + // Restore the SSO ticket so the client can reconnect with the same ticket if (ssoTicket != null && !ssoTicket.isEmpty()) { restoreSsoTicket(userId, ssoTicket); } + // Schedule the final disconnect after the grace period ScheduledFuture future = Emulator.getThreading().run(() -> { GhostSession ghost = ghostSessions.remove(userId); if (ghost != null) { @@ -60,12 +79,18 @@ public class SessionResumeManager { return true; } + /** + * Try to resume a ghost session for the given user ID. + * + * @return the parked Habbo if found within grace period, null otherwise + */ public Habbo resumeSession(int userId) { GhostSession ghost = ghostSessions.remove(userId); if (ghost == null) { return null; } + // Cancel the scheduled dispose if (ghost.disposeFuture != null) { ghost.disposeFuture.cancel(false); } @@ -76,10 +101,16 @@ public class SessionResumeManager { return ghost.habbo; } + /** + * Check if a user has a ghost session (is in grace period). + */ public boolean hasGhostSession(int userId) { return ghostSessions.containsKey(userId); } + /** + * Immediately expire all ghost sessions (e.g. on emulator shutdown). + */ public void disposeAll() { for (GhostSession ghost : ghostSessions.values()) { if (ghost.disposeFuture != null) { @@ -90,6 +121,9 @@ public class SessionResumeManager { ghostSessions.clear(); } + /** + * Perform the actual full disconnect that normally happens in Habbo.disconnect(). + */ private void performFullDisconnect(Habbo habbo) { try { habbo.getHabboInfo().setOnline(false); @@ -97,6 +131,8 @@ public class SessionResumeManager { } catch (Exception e) { LOGGER.error("[SessionResume] Error during deferred disconnect", e); } + + // Clear the SSO ticket now that the grace period is truly over clearSsoTicket(habbo.getHabboInfo().getId()); } diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomManager.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomManager.java index 0b652170..6348ca40 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomManager.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/rooms/RoomManager.java @@ -292,7 +292,7 @@ public class RoomManager { /** * Loads a room, optionally loading its data. * If the room is already being loaded in the background, this will wait for that to complete. - * + * * @param id The room ID * @param loadData Whether to load room data (items, bots, pets, etc.) * @return The loaded room, or null if not found @@ -499,14 +499,18 @@ public class RoomManager { } public void enterRoom(Habbo habbo, int roomId, String password) { - this.enterRoom(habbo, roomId, password, false, null); + this.enterRoom(habbo, roomId, password, false, null, false); } public void enterRoom(Habbo habbo, int roomId, String password, boolean overrideChecks) { - this.enterRoom(habbo, roomId, password, overrideChecks, null); + this.enterRoom(habbo, roomId, password, overrideChecks, null, false); } public void enterRoom(Habbo habbo, int roomId, String password, boolean overrideChecks, RoomTile doorLocation) { + this.enterRoom(habbo, roomId, password, overrideChecks, doorLocation, false); + } + + public void enterRoom(Habbo habbo, int roomId, String password, boolean overrideChecks, RoomTile doorLocation, boolean isReconnectSpawn) { Room room = this.loadRoom(roomId, true); if (room == null) @@ -547,7 +551,7 @@ public class RoomManager { room.hasRights(habbo) || (room.getState().equals(RoomState.INVISIBLE) && room.hasRights(habbo)) || (room.hasGuild() && room.getGuildRightLevel(habbo).isGreaterThan(RoomRightLevels.GUILD_RIGHTS))) { - this.openRoom(habbo, room, doorLocation); + this.openRoom(habbo, room, doorLocation, isReconnectSpawn); } else if (room.getState() == RoomState.LOCKED) { boolean rightsFound = false; @@ -572,7 +576,7 @@ public class RoomManager { room.addToQueue(habbo); } else if (room.getState() == RoomState.PASSWORD) { if (room.getPassword().equalsIgnoreCase(password)) - this.openRoom(habbo, room, doorLocation); + this.openRoom(habbo, room, doorLocation, isReconnectSpawn); else { habbo.getClient().sendResponse(new GenericErrorMessagesComposer(GenericErrorMessagesComposer.WRONG_PASSWORD_USED)); habbo.getClient().sendResponse(new HotelViewComposer()); @@ -585,6 +589,10 @@ public class RoomManager { } void openRoom(Habbo habbo, Room room, RoomTile doorLocation) { + this.openRoom(habbo, room, doorLocation, false); + } + + void openRoom(Habbo habbo, Room room, RoomTile doorLocation, boolean isReconnectSpawn) { if (room == null || room.getLayout() == null) return; @@ -623,7 +631,13 @@ public class RoomManager { if (doorLocation == null) { habbo.getRoomUnit().setBodyRotation(RoomUserRotation.values()[room.getLayout().getDoorDirection()]); habbo.getRoomUnit().setHeadRotation(RoomUserRotation.values()[room.getLayout().getDoorDirection()]); + } else if (isReconnectSpawn) { + // Reconnect spawn: place at tile but keep normal room behavior + // (user can still leave by door, no teleport flags) + habbo.getRoomUnit().setBodyRotation(RoomUserRotation.values()[room.getLayout().getDoorDirection()]); + habbo.getRoomUnit().setHeadRotation(RoomUserRotation.values()[room.getLayout().getDoorDirection()]); } else { + // Furniture teleport spawn habbo.getRoomUnit().setCanLeaveRoomByDoor(false); habbo.getRoomUnit().isTeleporting = true; HabboItem topItem = room.getTopItemAt(doorLocation.x, doorLocation.y); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java index e3ae9244..f2afd72c 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/handshake/SecureLoginEvent.java @@ -1,9 +1,9 @@ package com.eu.habbo.messages.incoming.handshake; import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.messenger.Messenger; import com.eu.habbo.habbohotel.gameclients.GameClient; import com.eu.habbo.habbohotel.gameclients.SessionResumeManager; -import com.eu.habbo.habbohotel.messenger.Messenger; import com.eu.habbo.habbohotel.modtool.ModToolSanctionItem; import com.eu.habbo.habbohotel.modtool.ModToolSanctions; import com.eu.habbo.habbohotel.navigation.NavigatorSavedSearch; @@ -84,14 +84,21 @@ public class SecureLoginEvent extends MessageHandler { } if (this.client.getHabbo() == null) { + // Store SSO ticket on client for grace period tracking this.client.setSsoTicket(sso); + // Race condition fix: if the old WebSocket connection is still alive on the + // server when the client reconnects, the SSO ticket won't be in the DB yet + // (it was cleared on first login, and parkHabbo hasn't run because the old + // channel hasn't closed). Find the old client by SSO ticket and force-dispose + // it, which parks the habbo and restores the ticket to the DB. GameClient existingClient = Emulator.getGameServer().getGameClientManager().findClientBySsoTicket(sso); if (existingClient != null && existingClient != this.client) { LOGGER.info("[SessionResume] Found existing client with same SSO ticket — disposing old connection to trigger parking"); Emulator.getGameServer().getGameClientManager().disposeClient(existingClient); } + // First, look up the user ID to check for ghost sessions int lookupUserId = 0; try (java.sql.Connection conn = Emulator.getDatabase().getDataSource().getConnection(); java.sql.PreparedStatement stmt = conn.prepareStatement("SELECT id FROM users WHERE auth_ticket = ? LIMIT 1")) { @@ -105,6 +112,7 @@ public class SecureLoginEvent extends MessageHandler { LOGGER.error("Caught exception looking up user for session resume", e); } + // Check if this user has a ghost session (disconnected within grace period) Habbo habbo = null; boolean isSessionResume = false; @@ -113,6 +121,7 @@ public class SecureLoginEvent extends MessageHandler { } if (habbo != null) { + // Session resume — reattach the existing Habbo to the new client isSessionResume = true; LOGGER.info("[SessionResume] Resuming session for {} (id={})", habbo.getHabboInfo().getUsername(), habbo.getHabboInfo().getId()); @@ -121,6 +130,7 @@ public class SecureLoginEvent extends MessageHandler { this.client.setHabbo(habbo); this.client.setMachineId(habbo.getHabboInfo().getMachineID()); + // Clear the SSO ticket now that session is resumed (prevent reuse) if (!Emulator.debugging) { try (java.sql.Connection conn = Emulator.getDatabase().getDataSource().getConnection(); java.sql.PreparedStatement stmt = conn.prepareStatement("UPDATE users SET auth_ticket = ? WHERE id = ? LIMIT 1")) { @@ -132,6 +142,7 @@ public class SecureLoginEvent extends MessageHandler { } } } else { + // Normal login — load from database habbo = Emulator.getGameEnvironment().getHabboManager().loadHabbo(sso); } @@ -177,6 +188,11 @@ public class SecureLoginEvent extends MessageHandler { int roomIdToEnter = 0; if (isSessionResume) { + // On session resume, DON'T set roomIdToEnter. The client keeps its + // existing room view alive and the habbo is already in the room on + // the server. Setting roomIdToEnter = 0 prevents UserHomeRoomComposer + // from triggering a full room re-entry on the client (which would + // tear down and rebuild the room view). Room currentRoom = habbo.getHabboInfo().getCurrentRoom(); if (currentRoom != null) { LOGGER.info("[SessionResume] {} is still in room {} — client will resume in-place", @@ -213,6 +229,8 @@ public class SecureLoginEvent extends MessageHandler { this.client.sendResponses(messages); + //Hardcoded + //this.client.sendResponse(new ForumsTestComposer()); this.client.sendResponse(new InventoryAchievementsComposer()); ModToolSanctions modToolSanctions = Emulator.getGameEnvironment().getModToolSanctions(); @@ -248,6 +266,7 @@ public class SecureLoginEvent extends MessageHandler { } } + // Skip login-only events on session resume (welcome alerts, login events, etc.) if (!isSessionResume) { UserLoginEvent userLoginEvent = new UserLoginEvent(habbo, this.client.getHabbo().getHabboInfo().getIpLogin()); Emulator.getPluginManager().fireEvent(userLoginEvent); diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/RequestRoomLoadEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/RequestRoomLoadEvent.java index b5cfffa9..45e2cf47 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/RequestRoomLoadEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/rooms/RequestRoomLoadEvent.java @@ -2,18 +2,38 @@ package com.eu.habbo.messages.incoming.rooms; import com.eu.habbo.Emulator; import com.eu.habbo.habbohotel.rooms.Room; +import com.eu.habbo.habbohotel.rooms.RoomTile; import com.eu.habbo.messages.incoming.MessageHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class RequestRoomLoadEvent extends MessageHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(RequestRoomLoadEvent.class); + @Override public void handle() throws Exception { int roomId = this.packet.readInt(); String password = this.packet.readString(); + // Optional spawn coordinates from the client (for future reconnection support). + int spawnX = -1; + int spawnY = -1; + + try { + int remaining = this.packet.getBuffer().readableBytes(); + if (remaining >= 8) { + spawnX = this.packet.readInt(); + spawnY = this.packet.readInt(); + } + } catch (Exception e) { + spawnX = -1; + spawnY = -1; + } + // Reset stale loadingRoom if timestamp has expired (indicates failed/stuck load) - if (this.client.getHabbo().getHabboInfo().getLoadingRoom() != 0 - && this.client.getHabbo().getHabboStats().roomEnterTimestamp + 5000 < System.currentTimeMillis()) { + if (this.client.getHabbo().getHabboInfo().getLoadingRoom() != 0 + && this.client.getHabbo().getHabboStats().roomEnterTimestamp + 5000 < System.currentTimeMillis()) { this.client.getHabbo().getHabboInfo().setLoadingRoom(0); } @@ -30,6 +50,18 @@ public class RequestRoomLoadEvent extends MessageHandler { Room room = this.client.getHabbo().getHabboInfo().getCurrentRoom(); if (room != null) { + // If re-entering the same room (session resume / reconnect), capture + // the user's current position before removal so we can respawn there. + if (room.getId() == roomId && spawnX < 0 && spawnY < 0 + && this.client.getHabbo().getRoomUnit() != null + && this.client.getHabbo().getRoomUnit().getCurrentLocation() != null) { + RoomTile currentLoc = this.client.getHabbo().getRoomUnit().getCurrentLocation(); + spawnX = currentLoc.x; + spawnY = currentLoc.y; + LOGGER.info("[RequestRoomLoadEvent] Re-entering same room {} — preserving position ({}, {})", + roomId, spawnX, spawnY); + } + Emulator.getGameEnvironment().getRoomManager().logExit(this.client.getHabbo()); room.removeHabbo(this.client.getHabbo(), true); @@ -41,7 +73,28 @@ public class RequestRoomLoadEvent extends MessageHandler { this.client.getHabbo().getRoomUnit().isTeleporting = false; } - Emulator.getGameEnvironment().getRoomManager().enterRoom(this.client.getHabbo(), roomId, password); + // Resolve spawn tile from coordinates (either from client or from saved position above) + RoomTile spawnTile = null; + + if (spawnX >= 0 && spawnY >= 0) { + Room targetRoom = Emulator.getGameEnvironment().getRoomManager().getRoom(roomId); + if (targetRoom == null) { + targetRoom = Emulator.getGameEnvironment().getRoomManager().loadRoom(roomId); + } + if (targetRoom != null && targetRoom.getLayout() != null) { + RoomTile tile = targetRoom.getLayout().getTile((short) spawnX, (short) spawnY); + if (tile != null && tile.isWalkable()) { + spawnTile = tile; + } + } + } + + boolean isReconnect = spawnTile != null; + LOGGER.debug("[RequestRoomLoadEvent] Entering room {} (spawnTile={}, isReconnect={})", + roomId, + spawnTile != null ? "(" + spawnTile.x + "," + spawnTile.y + ")" : "door", + isReconnect); + Emulator.getGameEnvironment().getRoomManager().enterRoom(this.client.getHabbo(), roomId, password, false, spawnTile, isReconnect); } } }