🆙 Stage 2 reconnect

This commit is contained in:
duckietm
2026-03-20 17:11:09 +01:00
parent 5807303807
commit aefc1e787b
4 changed files with 131 additions and 9 deletions
@@ -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());
}
@@ -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);
@@ -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);
@@ -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);
}
}
}