diff --git a/client/src/services/colyseus.ts b/client/src/services/colyseus.ts index 934739f..80650bf 100644 --- a/client/src/services/colyseus.ts +++ b/client/src/services/colyseus.ts @@ -139,6 +139,8 @@ class ColyseusService { // Update current session id for correct role mapping try { this.sessionId.value = (gameRoom as any).sessionId || this.sessionId.value; } catch {} console.log('gameRoom.value is now:', this.gameRoom.value); + // Register reconnection token on server for this UUID + try { (gameRoom as any).send("registerReconnection", (gameRoom as any).reconnectionToken || ""); } catch {} // Don't register message handlers here - let the Game component handle them @@ -172,6 +174,8 @@ class ColyseusService { try { this.sessionId.value = (gameRoom as any).sessionId || this.sessionId.value; } catch {} this.gameRoom.value = gameRoom; this.currentRoom = gameRoom; + // Register reconnection token on server for this UUID + try { (gameRoom as any).send("registerReconnection", (gameRoom as any).reconnectionToken || ""); } catch {} // Don't register message handlers here - let the Game component handle them @@ -316,6 +320,17 @@ class ColyseusService { } } + // Used when lobby provides a reconnection token to resume a locked room + async reconnectWithToken(token: string): Promise { + const room = await (this.client as any).reconnect(token); + this.gameRoom.value = room; + this.currentRoom = room; + try { this.sessionId.value = (room as any).sessionId || this.sessionId.value; } catch {} + // Refresh reconnection token mapping on server + try { (room as any).send("registerReconnection", (room as any).reconnectionToken || ""); } catch {} + return room; + } + private getUuidFromPath(): string { if (typeof window === 'undefined') return ''; const path = window.location.pathname.replace(/^\/+/, ''); diff --git a/client/src/views/Lobby.vue b/client/src/views/Lobby.vue index 3fb0d7c..3c89404 100644 --- a/client/src/views/Lobby.vue +++ b/client/src/views/Lobby.vue @@ -115,6 +115,36 @@ onMounted(async () => { const room = await colyseusService.joinLobby(); colorInput.value = colyseusService.playerColor.value || '#667eea'; + // Prefer reconnection token path to bypass locked rooms + room.onMessage("resumeReconnection", async (data: any) => { + try { + await colyseusService.reconnectWithToken(data.token); + // Leave lobby before navigating + if (colyseusService.lobbyRoom.value) { + colyseusService.lobbyRoom.value.leave(); + colyseusService.lobbyRoom.value = null; + } + await router.push(`/${routeUuid.value}/demo`); + } catch (error) { + console.error('Reconnection failed:', error); + } + }); + + // Listen for server-initiated resume to existing game (fallback joinById) + room.onMessage("resumeGame", async (data: any) => { + try { + const gameRoom = await colyseusService.joinGameRoom(data.roomId); + // Leave lobby before navigating + if (colyseusService.lobbyRoom.value) { + colyseusService.lobbyRoom.value.leave(); + colyseusService.lobbyRoom.value = null; + } + await router.push(`/${routeUuid.value}/demo`); + } catch (error) { + console.error('Auto-join failed:', error); + } + }); + // Keep color input synced with server-updated color watch(() => colyseusService.playerColor.value, (c) => { if (c && c !== colorInput.value) colorInput.value = c; diff --git a/server/src/rooms/GameRoom.ts b/server/src/rooms/GameRoom.ts index ceb69fc..dceb099 100644 --- a/server/src/rooms/GameRoom.ts +++ b/server/src/rooms/GameRoom.ts @@ -226,6 +226,16 @@ export class GameRoom extends Room { // Removed nextRound handler - rounds now auto-advance + // Persist reconnection token per UUID (sent by client after join) + this.onMessage("registerReconnection", (client, token: string) => { + const uuid = this.sessionToUuid.get(client.sessionId); + if (!uuid) return; + try { + const { NameManager } = require("../utils/nameManager"); + NameManager.getInstance().setReconnectToken(uuid, (token || '').toString()); + } catch {} + }); + this.onMessage("admin:kick", (client, playerId: string) => { this.handleKick(playerId); }); @@ -233,6 +243,44 @@ export class GameRoom extends Room { onJoin(client: Client, options: any) { console.log(`[GameRoom] ${client.sessionId} joined room ${this.roomId} with name: ${options.playerName}`); + const uuid = options?.uuid as string | undefined; + + // UUID-based reconnection: if game already started or room is full, allow join if UUID matches a participant + if ((this.state.gameStatus !== GameStatus.WAITING || this.state.players.size >= 2) && uuid) { + // Try to find a matching player by UUID + let foundKey: string | null = null; + this.state.players.forEach((p, key) => { + if ((p as any).uuid && (p as any).uuid === uuid) { + foundKey = key; + } + }); + if (foundKey) { + // Rebind player to new sessionId + const player = this.state.players.get(foundKey!); + if (player) { + this.state.players.delete(foundKey!); + player.sessionId = client.sessionId; + player.connected = true; + this.state.players.set(client.sessionId, player); + if (this.state.p1Id === foundKey) this.state.p1Id = client.sessionId; + if (this.state.p2Id === foundKey) this.state.p2Id = client.sessionId; + this.sessionToUuid.set(client.sessionId, uuid); + // Let client know identity + client.send("playerInfo", { sessionId: client.sessionId, name: player.name, roomId: this.roomId }); + // If paused and both connected, resume + if (this.state.gameStatus === GameStatus.PAUSED && this.getConnectedPlayersCount() === 2) { + this.state.resumeGame(); + this.setMetadata({ + gameStatus: 'playing', + currentRound: this.state.currentRound, + currentVariant: this.state.currentVariant + }); + broadcastDashboardUpdate(); + } + return; // Do not proceed with normal join flow + } + } + } // Special handling for shuffled players if (this.isWaitingForShuffledPlayers && options.uuid) { @@ -246,8 +294,8 @@ export class GameRoom extends Room { } // Store UUID mapping if provided - if (options.uuid) { - this.sessionToUuid.set(client.sessionId, options.uuid); + if (uuid) { + this.sessionToUuid.set(client.sessionId, uuid); } // Use the playerName passed from the lobby - don't generate a new one! @@ -259,6 +307,7 @@ export class GameRoom extends Room { const p = this.state.players.get(client.sessionId); if (p) { p.color = playerColor; + if (uuid) (p as any).uuid = uuid; } client.send("playerInfo", { @@ -280,6 +329,14 @@ export class GameRoom extends Room { if (this.state.players.size === 2 && this.state.gameStatus === GameStatus.WAITING) { this.startGame(); } + + // Persist current room mapping by UUID + if (uuid) { + try { + const { NameManager } = require("../utils/nameManager"); + NameManager.getInstance().setCurrentRoom(uuid, this.roomId); + } catch {} + } } onLeave(client: Client, consented: boolean) { @@ -400,6 +457,20 @@ export class GameRoom extends Room { }); // Notify dashboard of game end broadcastDashboardUpdate(); + + // Clear UUID -> current room mapping and reconnection tokens for participants + try { + const { NameManager } = require("../utils/nameManager"); + const toClear: string[] = []; + this.state.players.forEach((p) => { + const u = (p as any)?.uuid; + if (u) toClear.push(u); + }); + toClear.forEach(u => { + NameManager.getInstance().clearCurrentRoom(u); + NameManager.getInstance().clearReconnectToken(u); + }); + } catch {} } private resolveP2Action() { diff --git a/server/src/rooms/LobbyRoom.ts b/server/src/rooms/LobbyRoom.ts index 292211f..31d6322 100644 --- a/server/src/rooms/LobbyRoom.ts +++ b/server/src/rooms/LobbyRoom.ts @@ -44,6 +44,32 @@ export class LobbyRoom extends Room { // Store UUID mapping if provided if (options.uuid) { this.sessionToUuid.set(client.sessionId, options.uuid); + + // If this UUID has a current active game room assignment, redirect there + try { + const currentRoomId = NameManager.getInstance().getCurrentRoom(options.uuid); + if (currentRoomId) { + // Verify room exists and is not finished + matchMaker.query({ roomId: currentRoomId }).then((rooms) => { + const room = rooms[0]; + const status = room?.metadata?.gameStatus || "waiting"; + if (room && status !== "finished") { + const token = NameManager.getInstance().getReconnectToken(options.uuid); + if (token) { + // Prefer reconnection token to bypass room lock + client.send("resumeReconnection", { token }); + } else { + // Fallback to joinById path + client.send("resumeGame", { roomId: currentRoomId }); + } + // Mark as in game on lobby state to avoid duplicate quickPlay + const p = this.state.players.get(client.sessionId); + if (p) p.inGame = true; + return; + } + }).catch(() => {}); + } + } catch {} // Check for shuffle redirect FIRST if (NameManager.getInstance().isShuffleInProgress()) { diff --git a/server/src/rooms/schemas/Player.ts b/server/src/rooms/schemas/Player.ts index 9cc75e4..de6e928 100644 --- a/server/src/rooms/schemas/Player.ts +++ b/server/src/rooms/schemas/Player.ts @@ -2,6 +2,7 @@ import { Schema, type } from "@colyseus/schema"; export class Player extends Schema { @type("string") sessionId: string = ""; + @type("string") uuid: string = ""; @type("string") name: string = ""; @type("number") clicks: number = 0; @type("boolean") connected: boolean = true; @@ -22,6 +23,7 @@ export class Player extends Schema { this.eloteTokens = 0; this.shameTokens = 0; this.color = "#667eea"; + this.uuid = ""; } incrementClicks(): void { diff --git a/server/src/utils/nameManager.ts b/server/src/utils/nameManager.ts index 3795309..9fd2623 100644 --- a/server/src/utils/nameManager.ts +++ b/server/src/utils/nameManager.ts @@ -6,6 +6,8 @@ export class NameManager { // For shuffle functionality private roomAssignments: Map = new Map(); private shuffleInProgress: boolean = false; + private uuidToCurrentRoom: Map = new Map(); + private uuidToReconnectToken: Map = new Map(); private constructor() {} @@ -68,6 +70,34 @@ export class NameManager { return Array.from(this.uuidToName.values()); } + // Current game room assignment (for reconnection by UUID) + setCurrentRoom(uuid: string, roomId: string): void { + if (!uuid || !roomId) return; + this.uuidToCurrentRoom.set(uuid, roomId); + } + + getCurrentRoom(uuid: string): string | undefined { + return this.uuidToCurrentRoom.get(uuid); + } + + clearCurrentRoom(uuid: string): void { + this.uuidToCurrentRoom.delete(uuid); + } + + // Reconnection token per UUID (to join locked rooms) + setReconnectToken(uuid: string, token: string): void { + if (!uuid || !token) return; + this.uuidToReconnectToken.set(uuid, token); + } + + getReconnectToken(uuid: string): string | undefined { + return this.uuidToReconnectToken.get(uuid); + } + + clearReconnectToken(uuid: string): void { + this.uuidToReconnectToken.delete(uuid); + } + // Shuffle functionality methods startShuffle(): void { this.shuffleInProgress = true;