diff --git a/client/src/services/colyseus.ts b/client/src/services/colyseus.ts index 84475cb..3f3ada8 100644 --- a/client/src/services/colyseus.ts +++ b/client/src/services/colyseus.ts @@ -68,6 +68,7 @@ class ColyseusService { } else { // No client-side persistence; user must confirm name once per session } + // Resume request is now triggered by Lobby.vue after listeners are attached }); room.onMessage("nameUpdated", (data) => { @@ -186,6 +187,37 @@ class ColyseusService { } } + async joinShuffledGameRoom(roomId: string, role: string, playerName: string, playerColor: string): Promise { + try { + const uuid = this.getUuidFromPath(); + console.log(`[Colyseus] Joining shuffled room ${roomId} as ${role} with UUID ${uuid}`); + + // Update local values + this.playerName.value = playerName; + this.playerColor.value = playerColor; + + const gameRoom = await this.client.joinById(roomId, { + playerName, + playerColor, + uuid, + isShuffleJoin: true, + role + }); + + 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 {} + + return gameRoom; + } catch (error) { + console.error("Failed to join shuffled game room:", error); + throw error; + } + } + sendClick(): void { if (this.gameRoom.value) { this.gameRoom.value.send("click"); diff --git a/client/src/views/Lobby.vue b/client/src/views/Lobby.vue index 99b8a3e..5471d75 100644 --- a/client/src/views/Lobby.vue +++ b/client/src/views/Lobby.vue @@ -137,19 +137,72 @@ onMounted(async () => { // Listen for server-initiated resume to existing game (fallback joinById) room.onMessage("resumeGame", async (data: any) => { if (guardResume()) return; - try { - const gameRoom = await colyseusService.joinGameRoom(data.roomId); - // Leave lobby before navigating - if (colyseusService.lobbyRoom.value) { - colyseusService.lobbyRoom.value.leave(); - colyseusService.lobbyRoom.value = null; + const tryJoin = async (attempt = 1): Promise => { + try { + const gameRoom = await colyseusService.joinGameRoom(data.roomId); + if (colyseusService.lobbyRoom.value) { + colyseusService.lobbyRoom.value.leave(); + colyseusService.lobbyRoom.value = null; + } + await router.push(`/${routeUuid.value}/demo`); + } catch (error: any) { + const msg = String(error?.message || error); + if (attempt < 3 && (msg.includes('locked') || msg.includes('full'))) { + setTimeout(() => tryJoin(attempt + 1), 300); + return; + } + console.error('Auto-join failed:', error); + // allow future resume if this one failed entirely + resumed = false; } - await router.push(`/${routeUuid.value}/demo`); - } catch (error) { - console.error('Auto-join failed:', error); - } + }; + await tryJoin(1); }); + // Listen for shuffle redirect with complete player information + room.onMessage("shuffleRedirect", async (data: any) => { + if (guardResume()) return; + console.log('[Lobby] Received shuffle redirect:', data); + + // Update player info before joining + if (data.playerName) { + colyseusService.playerName.value = data.playerName; + } + if (data.playerColor) { + colyseusService.playerColor.value = data.playerColor; + } + + const tryJoin = async (attempt = 1): Promise => { + try { + // Join with shuffle flag to bypass normal restrictions + const gameRoom = await colyseusService.joinShuffledGameRoom( + data.roomId, + data.role, + data.playerName, + data.playerColor + ); + + if (colyseusService.lobbyRoom.value) { + colyseusService.lobbyRoom.value.leave(); + colyseusService.lobbyRoom.value = null; + } + await router.push(`/${routeUuid.value}/demo`); + } catch (error: any) { + const msg = String(error?.message || error); + if (attempt < 3) { + setTimeout(() => tryJoin(attempt + 1), 500); + return; + } + console.error('Shuffle join failed:', error); + resumed = false; + } + }; + await tryJoin(1); + }); + + // After listeners are attached, ask server if we should resume (shuffle/currentRoom) + try { room.send("resumeMe"); } catch {} + // 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 dceb099..1d57d4c 100644 --- a/server/src/rooms/GameRoom.ts +++ b/server/src/rooms/GameRoom.ts @@ -6,6 +6,26 @@ import { broadcastDashboardUpdate } from "../adminApi"; export class GameRoom extends Room { maxClients = 2; + + getAvailableData() { + // If waiting for shuffled players, report as available regardless of current client count + if (this.isWaitingForShuffledPlayers) { + return { + clients: 0, + maxClients: this.maxClients, + metadata: this.metadata + }; + } + return super.getAvailableData(); + } + + hasReachedMaxClients() { + // If waiting for shuffled players, allow up to 2 new clients regardless of current count + if (this.isWaitingForShuffledPlayers) { + return false; + } + return super.hasReachedMaxClients(); + } private gameInterval?: NodeJS.Timeout; private recentSystemMessage: { text: string; kind: string; timestamp: number } | null = null; @@ -242,7 +262,7 @@ export class GameRoom extends Room { } onJoin(client: Client, options: any) { - console.log(`[GameRoom] ${client.sessionId} joined room ${this.roomId} with name: ${options.playerName}`); + console.log(`[GameRoom] ${client.sessionId} joined room ${this.roomId} with name: ${options.playerName}, isShuffleJoin: ${options.isShuffleJoin}, isWaitingForShuffledPlayers: ${this.isWaitingForShuffledPlayers}, playersCount: ${this.state.players.size}`); 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 @@ -283,12 +303,13 @@ export class GameRoom extends Room { } // Special handling for shuffled players - if (this.isWaitingForShuffledPlayers && options.uuid) { + if ((this.isWaitingForShuffledPlayers || options.isShuffleJoin) && options.uuid) { return this.handleShuffledPlayerJoin(client, options); } // Prevent new joins if game already started or two players are registered - if (this.state.gameStatus !== GameStatus.WAITING || this.state.players.size >= 2) { + // BUT allow if waiting for shuffled players (they should go through shuffle handler above) + if ((this.state.gameStatus !== GameStatus.WAITING || this.state.players.size >= 2) && !this.isWaitingForShuffledPlayers) { try { client.leave(1000); } catch {} return; } @@ -685,10 +706,18 @@ export class GameRoom extends Room { } }); + // Clear all players from state completely + this.state.players.clear(); + this.state.p1Id = ""; + this.state.p2Id = ""; + this.sessionToUuid.clear(); + // Reset room state but keep variant const currentVariant = this.state.currentVariant; this.state.restartGame(); this.state.currentVariant = currentVariant; + // Ensure room is accepting new joins after shuffle + try { (this as any).unlock?.(); } catch {} // Prepare for new players this.isWaitingForShuffledPlayers = true; @@ -704,42 +733,75 @@ export class GameRoom extends Room { this.expectedShuffledPlayers = assignments; this.isWaitingForShuffledPlayers = true; - // Update metadata + // Update metadata to reflect that room is waiting for shuffled players this.setMetadata({ gameStatus: 'waiting', currentRound: 1, - currentVariant: this.state.currentVariant + currentVariant: this.state.currentVariant, + playersCount: 0, // Force reset player count + maxClients: 2 }); + + // Make sure the room is unlocked and can accept new connections + try { + (this as any).unlock?.(); + // Force Colyseus to recalculate available slots + (this as any).maxClients = 2; + } catch {} } private handleShuffledPlayerJoin(client: Client, options: any) { const uuid = options.uuid; - console.log(`[GameRoom] Shuffled player ${uuid} trying to join room ${this.roomId}`); + console.log(`[GameRoom] Shuffled player ${uuid} trying to join room ${this.roomId} with options:`, options); + console.log(`[GameRoom] Room state - isWaitingForShuffledPlayers: ${this.isWaitingForShuffledPlayers}, playersCount: ${this.state.players.size}, expectedPlayers:`, this.expectedShuffledPlayers); // Check if this player is expected in this room - let expectedRole: 'P1' | 'P2' | null = null; + let expectedRole: 'P1' | 'P2' | null = options.role || null; - if (this.expectedShuffledPlayers.p1?.uuid === uuid) { - expectedRole = 'P1'; - } else if (this.expectedShuffledPlayers.p2?.uuid === uuid) { - expectedRole = 'P2'; + // If role not provided, determine from expected players + if (!expectedRole) { + if (this.expectedShuffledPlayers.p1?.uuid === uuid) { + expectedRole = 'P1'; + } else if (this.expectedShuffledPlayers.p2?.uuid === uuid) { + expectedRole = 'P2'; + } } + if (!expectedRole) { + // Fallback: consult NameManager assignment if not set yet in room + try { + const { NameManager } = require("../utils/nameManager"); + const assign = NameManager.getInstance().getPlayerRoomAssignment(uuid); + if (assign && assign.roomId === this.roomId) { + expectedRole = assign.role; + if (assign.role === 'P1' && !this.expectedShuffledPlayers.p1) { + this.expectedShuffledPlayers.p1 = { uuid, name: options.playerName || 'player', color: options.playerColor }; + } else if (assign.role === 'P2' && !this.expectedShuffledPlayers.p2) { + this.expectedShuffledPlayers.p2 = { uuid, name: options.playerName || 'player', color: options.playerColor }; + } + } + } catch {} + } + if (!expectedRole) { console.log(`[GameRoom] Player ${uuid} not expected in room ${this.roomId}, rejecting`); try { client.leave(1000); } catch {} return; } - // Get player info from expected data + // Get player info from expected data or use provided options as fallback const expectedPlayerData = expectedRole === 'P1' ? this.expectedShuffledPlayers.p1 : this.expectedShuffledPlayers.p2; + const playerName = expectedPlayerData?.name || options.playerName || 'player'; + const playerColor = expectedPlayerData?.color || options.playerColor || "#667eea"; console.log(`[GameRoom] Adding shuffled player ${uuid} as ${expectedRole} in room ${this.roomId}`); // Add player with the expected role - const player = this.state.addPlayer(client.sessionId, expectedPlayerData.name); + const player = this.state.addPlayer(client.sessionId, playerName); player.role = expectedRole; - player.color = expectedPlayerData.color || "#667eea"; + player.color = playerColor; + (player as any).uuid = uuid; + this.sessionToUuid.set(client.sessionId, uuid); // Set role IDs if (expectedRole === 'P1') { @@ -750,12 +812,16 @@ export class GameRoom extends Room { client.send("playerInfo", { sessionId: client.sessionId, - name: expectedPlayerData.name, + name: playerName, roomId: this.roomId }); - // Remove from NameManager assignment once joined - NameManager.getInstance().removePlayerRoomAssignment(uuid); + // Update mappings in NameManager + try { + const { NameManager } = require("../utils/nameManager"); + NameManager.getInstance().setCurrentRoom(uuid, this.roomId); + NameManager.getInstance().removePlayerRoomAssignment(uuid); + } catch {} // Check if room is complete if (this.state.players.size === 2) { diff --git a/server/src/rooms/LobbyRoom.ts b/server/src/rooms/LobbyRoom.ts index 502383e..dfa3b82 100644 --- a/server/src/rooms/LobbyRoom.ts +++ b/server/src/rooms/LobbyRoom.ts @@ -44,6 +44,52 @@ export class LobbyRoom extends Room { this.handleJoinRoom(client, roomId); }); + // Client asks server to suggest a resume target (after listeners are ready) + this.onMessage("resumeMe", async (client) => { + const uuid = this.sessionToUuid.get(client.sessionId); + if (!uuid) return; + + // If shuffle in progress and an assignment exists, prefer it + if (NameManager.getInstance().isShuffleInProgress()) { + const assignment = NameManager.getInstance().getPlayerRoomAssignment(uuid); + if (assignment) { + try { + const delay = assignment.role === 'P1' ? 150 : 750; // stagger joins to avoid full race + const playerName = NameManager.getInstance().getPlayerName(uuid) || ""; + const playerColor = NameManager.getInstance().getPlayerColor(uuid) || "#667eea"; + + setTimeout(() => { + try { + client.send("shuffleRedirect", { + roomId: assignment.roomId, + role: assignment.role, + playerName, + playerColor, + isShuffleJoin: true + }); + } catch {} + }, delay); + } catch {} + return; + } + } + + // Otherwise, try current room mapping + try { + const currentRoomId = NameManager.getInstance().getCurrentRoom(uuid); + if (currentRoomId) { + const rooms = await matchMaker.query({ roomId: currentRoomId }); + const room = rooms[0]; + const status = room?.metadata?.gameStatus || "waiting"; + if (room && status !== "finished") { + const token = NameManager.getInstance().getReconnectToken(uuid); + if (token) { client.send("resumeReconnection", { token }); } + else { client.send("resumeGame", { roomId: currentRoomId }); } + } + } + } catch {} + }); + this.updateInterval = setInterval(() => { this.updateAvailableRooms(); }, 2000); @@ -88,12 +134,8 @@ export class LobbyRoom extends Room { name: existingName || "", color: this.state.players.get(client.sessionId)?.color || "#667eea" }); - - // Then immediately redirect to assigned room - setTimeout(() => { - this.handleJoinRoom(client, assignment.roomId); - }, 500); - + // Do not push immediate redirect here; client will send "resumeMe" after handlers are ready + return; } } @@ -237,7 +279,7 @@ export class LobbyRoom extends Room { } } - private async handleJoinRoom(client: Client, roomId: string) { + private async handleJoinRoom(client: Client, roomId: string, opts?: { force?: boolean }) { const player = this.state.players.get(client.sessionId); if (!player || player.inGame) return; if (!player.name || !player.name.trim()) { @@ -246,12 +288,13 @@ export class LobbyRoom extends Room { } try { - // Verify the room exists and is available - const rooms = await matchMaker.query({ roomId }); - - const status = rooms[0]?.metadata?.gameStatus || "waiting"; - if (rooms.length === 0 || rooms[0].clients >= 2 || status !== "waiting") { - throw new Error("Room not available"); + if (!opts?.force) { + // Verify the room exists and is available + const rooms = await matchMaker.query({ roomId }); + const status = rooms[0]?.metadata?.gameStatus || "waiting"; + if (rooms.length === 0 || rooms[0].clients >= 2 || status !== "waiting") { + throw new Error("Room not available"); + } } this.state.setPlayerInGame(client.sessionId, true);