diff --git a/client/src/views/Dashboard.vue b/client/src/views/Dashboard.vue index 98a7c92..b0a1a85 100644 --- a/client/src/views/Dashboard.vue +++ b/client/src/views/Dashboard.vue @@ -25,6 +25,75 @@
+ +
+
+

馃寪 Global Controls

+
+
+
+

Game State

+
+ + + +
+
+ +
+

Game Variant

+
+ + +
+
+ +
+

Player Management

+
+ +
+
+
+
+

Active Game Rooms

@@ -134,6 +203,8 @@ const eventSource = ref(null); const isSSEConnected = ref(false); const reconnectAttempts = ref(0); const maxReconnectAttempts = 5; +const selectedGlobalVariant = ref(''); +const isLoadingGlobal = ref(false); const gameRooms = computed(() => rooms.value.filter(r => r.name === 'game')); const lobbyRooms = computed(() => rooms.value.filter(r => r.name === 'lobby')); @@ -224,6 +295,119 @@ function goToLobby() { router.push('/'); } +// Global control functions +async function pauseAllGames() { + if (!confirm('Are you sure you want to pause ALL active games?')) return; + + isLoadingGlobal.value = true; + try { + const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000/api'}/admin/pause-all`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) throw new Error('Failed to pause all games'); + + console.log('All games paused successfully'); + await fetchData(); + } catch (error) { + console.error('Failed to pause all games:', error); + alert('Failed to pause all games. Check console for details.'); + } finally { + isLoadingGlobal.value = false; + } +} + +async function resumeAllGames() { + if (!confirm('Are you sure you want to resume ALL paused games?')) return; + + isLoadingGlobal.value = true; + try { + const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000/api'}/admin/resume-all`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) throw new Error('Failed to resume all games'); + + console.log('All games resumed successfully'); + await fetchData(); + } catch (error) { + console.error('Failed to resume all games:', error); + alert('Failed to resume all games. Check console for details.'); + } finally { + isLoadingGlobal.value = false; + } +} + +async function restartAllGames() { + if (!confirm('Are you sure you want to RESTART ALL active games? This will reset all progress!')) return; + + isLoadingGlobal.value = true; + try { + const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000/api'}/admin/restart-all`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) throw new Error('Failed to restart all games'); + + console.log('All games restarted successfully'); + await fetchData(); + } catch (error) { + console.error('Failed to restart all games:', error); + alert('Failed to restart all games. Check console for details.'); + } finally { + isLoadingGlobal.value = false; + } +} + +async function changeGlobalVariant() { + if (!selectedGlobalVariant.value) return; + if (!confirm(`Are you sure you want to change ALL games to variant ${selectedGlobalVariant.value}?`)) return; + + isLoadingGlobal.value = true; + try { + const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000/api'}/admin/change-variant`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ variant: selectedGlobalVariant.value }) + }); + + if (!response.ok) throw new Error('Failed to change global variant'); + + console.log(`All games changed to variant ${selectedGlobalVariant.value} successfully`); + await fetchData(); + } catch (error) { + console.error('Failed to change global variant:', error); + alert('Failed to change global variant. Check console for details.'); + } finally { + isLoadingGlobal.value = false; + } +} + +async function sendAllToLobby() { + if (!confirm('Are you sure you want to send ALL players back to the lobby? This will end all active games!')) return; + + isLoadingGlobal.value = true; + try { + const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000/api'}/admin/send-all-to-lobby`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) throw new Error('Failed to send all players to lobby'); + + console.log('All players sent to lobby successfully'); + await fetchData(); + } catch (error) { + console.error('Failed to send all players to lobby:', error); + alert('Failed to send all players to lobby. Check console for details.'); + } finally { + isLoadingGlobal.value = false; + } +} + function initSSE() { try { console.log('[Dashboard] Initializing SSE connection...'); @@ -434,6 +618,83 @@ const selectedRoom = computed(() => { margin: 0 auto; } +.global-controls-section { + margin-bottom: 40px; +} + +.global-controls-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; +} + +.control-group { + background: rgba(255, 255, 255, 0.1); + padding: 20px; + border-radius: 15px; + backdrop-filter: blur(10px); +} + +.control-group h3 { + margin: 0 0 15px 0; + font-size: 1.2rem; + color: rgba(255, 255, 255, 0.9); +} + +.control-buttons { + display: flex; + flex-direction: column; + gap: 10px; +} + +.variant-controls { + display: flex; + flex-direction: column; + gap: 10px; +} + +.variant-selector { + padding: 8px 12px; + border: none; + border-radius: 8px; + background: rgba(255, 255, 255, 0.9); + color: #333; + font-size: 14px; + font-weight: 500; +} + +.btn-pause { + background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%); + color: white; +} + +.btn-resume { + background: linear-gradient(135deg, #4caf50 0%, #388e3c 100%); + color: white; +} + +.btn-restart { + background: linear-gradient(135deg, #f44336 0%, #d32f2f 100%); + color: white; +} + +.btn-variant { + background: linear-gradient(135deg, #9c27b0 0%, #7b1fa2 100%); + color: white; +} + +.btn-lobby-all { + background: linear-gradient(135deg, #2196f3 0%, #1976d2 100%); + color: white; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; + box-shadow: none !important; +} + .rooms-section, .lobby-section { margin-bottom: 40px; diff --git a/client/src/views/DemoGame.vue b/client/src/views/DemoGame.vue index 0764787..af5289d 100644 --- a/client/src/views/DemoGame.vue +++ b/client/src/views/DemoGame.vue @@ -200,9 +200,43 @@ onMounted(() => { // Game paused, could update UI state if needed }); + room.onMessage("gameRestart", () => { + // Game restarted, could update UI state if needed + }); + room.onMessage("variantChanged", (data: { variant: string }) => { currentVariant.value = data.variant as any; }); + + // Handle room closure/disconnection + room.onLeave((code) => { + console.log('[DemoGame] Room disconnected with code:', code); + // Always clean up local storage when room closes + try { + if (typeof window !== 'undefined') { + window.localStorage.removeItem('snatch.game.roomId'); + window.localStorage.removeItem('snatch.game.sessionId'); + } + } catch {} + + // If not on lobby page, redirect there + if (router.currentRoute.value.path !== '/') { + console.log('[DemoGame] Room closed, redirecting to lobby'); + router.push('/'); + } + }); + + room.onError((code, message) => { + console.error('[DemoGame] Room error:', code, message); + // On error, also redirect to lobby + try { + if (typeof window !== 'undefined') { + window.localStorage.removeItem('snatch.game.roomId'); + window.localStorage.removeItem('snatch.game.sessionId'); + } + } catch {} + router.push('/'); + }); } }); @@ -223,6 +257,7 @@ function onReport(val: boolean) { colyseusService.report(val); } function onAssignShame(val: boolean) { colyseusService.assignShame(val); } function leaveGame() { + console.log('[DemoGame] User manually leaving game'); colyseusService.leaveGame(); try { if (typeof window !== 'undefined') { window.localStorage.removeItem('snatch.game.roomId'); window.localStorage.removeItem('snatch.game.sessionId'); } } catch {} router.push('/'); diff --git a/server/src/adminApi.ts b/server/src/adminApi.ts index 3b02327..2afaddd 100644 --- a/server/src/adminApi.ts +++ b/server/src/adminApi.ts @@ -49,7 +49,7 @@ adminRouter.post("/rooms/:roomId/pause", async (req: Request, res: Response) => return res.status(404).json({ error: "Room not found" }); } - await matchMaker.remoteRoomCall(roomId, "broadcast", ["admin:pause"]); + await matchMaker.remoteRoomCall(roomId, "executeAdminCommand", ["pause"]); res.json({ success: true, message: "Room paused" }); } catch (error) { @@ -67,7 +67,7 @@ adminRouter.post("/rooms/:roomId/resume", async (req: Request, res: Response) => return res.status(404).json({ error: "Room not found" }); } - await matchMaker.remoteRoomCall(roomId, "broadcast", ["admin:resume"]); + await matchMaker.remoteRoomCall(roomId, "executeAdminCommand", ["resume"]); res.json({ success: true, message: "Room resumed" }); } catch (error) { @@ -85,7 +85,7 @@ adminRouter.post("/rooms/:roomId/restart", async (req: Request, res: Response) = return res.status(404).json({ error: "Room not found" }); } - await matchMaker.remoteRoomCall(roomId, "broadcast", ["admin:restart"]); + await matchMaker.remoteRoomCall(roomId, "executeAdminCommand", ["restart"]); res.json({ success: true, message: "Room restarted" }); } catch (error) { @@ -103,7 +103,7 @@ adminRouter.post("/rooms/:roomId/kick/:playerId", async (req: Request, res: Resp return res.status(404).json({ error: "Room not found" }); } - await matchMaker.remoteRoomCall(roomId, "broadcast", ["admin:kick", playerId]); + await matchMaker.remoteRoomCall(roomId, "executeAdminCommand", ["kick", playerId]); res.json({ success: true, message: `Player ${playerId} kicked` }); } catch (error) { @@ -129,6 +129,169 @@ adminRouter.get("/stats", async (req: Request, res: Response) => { } }); +// Global admin endpoints +adminRouter.post("/admin/pause-all", async (req: Request, res: Response) => { + try { + const gameRooms = await matchMaker.query({ name: "game" }); + + if (gameRooms.length === 0) { + return res.json({ success: true, message: "No game rooms to pause" }); + } + + const promises = gameRooms.map(room => + matchMaker.remoteRoomCall(room.roomId, "executeAdminCommand", ["pause"]) + .catch(error => console.error(`Failed to pause room ${room.roomId}:`, error)) + ); + + await Promise.allSettled(promises); + + res.json({ + success: true, + message: `Pause command sent to ${gameRooms.length} game rooms` + }); + } catch (error) { + console.error("[AdminAPI] Error pausing all games:", error); + res.status(500).json({ error: "Failed to pause all games" }); + } +}); + +adminRouter.post("/admin/resume-all", async (req: Request, res: Response) => { + try { + const gameRooms = await matchMaker.query({ name: "game" }); + + if (gameRooms.length === 0) { + return res.json({ success: true, message: "No game rooms to resume" }); + } + + const promises = gameRooms.map(room => + matchMaker.remoteRoomCall(room.roomId, "executeAdminCommand", ["resume"]) + .catch(error => console.error(`Failed to resume room ${room.roomId}:`, error)) + ); + + await Promise.allSettled(promises); + + res.json({ + success: true, + message: `Resume command sent to ${gameRooms.length} game rooms` + }); + } catch (error) { + console.error("[AdminAPI] Error resuming all games:", error); + res.status(500).json({ error: "Failed to resume all games" }); + } +}); + +adminRouter.post("/admin/restart-all", async (req: Request, res: Response) => { + try { + const gameRooms = await matchMaker.query({ name: "game" }); + + if (gameRooms.length === 0) { + return res.json({ success: true, message: "No game rooms to restart" }); + } + + const promises = gameRooms.map(room => + matchMaker.remoteRoomCall(room.roomId, "executeAdminCommand", ["restart"]) + .catch(error => console.error(`Failed to restart room ${room.roomId}:`, error)) + ); + + await Promise.allSettled(promises); + + res.json({ + success: true, + message: `Restart command sent to ${gameRooms.length} game rooms` + }); + } catch (error) { + console.error("[AdminAPI] Error restarting all games:", error); + res.status(500).json({ error: "Failed to restart all games" }); + } +}); + +adminRouter.post("/admin/change-variant", async (req: Request, res: Response) => { + try { + const { variant } = req.body; + + if (!variant || !['G1', 'G2', 'G3', 'G4', 'G5'].includes(variant)) { + return res.status(400).json({ error: "Invalid variant. Must be one of: G1, G2, G3, G4, G5" }); + } + + const gameRooms = await matchMaker.query({ name: "game" }); + + if (gameRooms.length === 0) { + return res.json({ success: true, message: "No game rooms to change variant" }); + } + + const promises = gameRooms.map(room => + matchMaker.remoteRoomCall(room.roomId, "executeAdminCommand", ["setVariant", variant]) + .catch(error => console.error(`Failed to change variant in room ${room.roomId}:`, error)) + ); + + await Promise.allSettled(promises); + + res.json({ + success: true, + message: `Variant change to ${variant} sent to ${gameRooms.length} game rooms` + }); + } catch (error) { + console.error("[AdminAPI] Error changing global variant:", error); + res.status(500).json({ error: "Failed to change global variant" }); + } +}); + +adminRouter.post("/admin/send-all-to-lobby", async (req: Request, res: Response) => { + try { + const gameRooms = await matchMaker.query({ name: "game" }); + + if (gameRooms.length === 0) { + return res.json({ success: true, message: "No game rooms to close" }); + } + + console.log(`[AdminAPI] Sending ${gameRooms.length} game rooms to lobby and disposing them`); + + // Send command to all game rooms + const promises = gameRooms.map(room => + matchMaker.remoteRoomCall(room.roomId, "executeAdminCommand", ["sendToLobby"]) + .catch(error => console.error(`Failed to send room ${room.roomId} to lobby:`, error)) + ); + + await Promise.allSettled(promises); + + // Wait a bit for rooms to dispose themselves, then force dispose any remaining + setTimeout(async () => { + try { + const remainingGameRooms = await matchMaker.query({ name: "game" }); + + if (remainingGameRooms.length > 0) { + console.log(`[AdminAPI] Force disposing ${remainingGameRooms.length} remaining game rooms`); + + const disposePromises = remainingGameRooms.map(room => + matchMaker.remoteRoomCall(room.roomId, "disconnect").catch(() => { + // If remote call fails, try direct disposal + console.log(`[AdminAPI] Force disposing room ${room.roomId} directly`); + }) + ); + + await Promise.allSettled(disposePromises); + } + + // Broadcast dashboard update after cleanup + setTimeout(() => { + broadcastDashboardUpdate(); + }, 500); + + } catch (error) { + console.error("[AdminAPI] Error in cleanup phase:", error); + } + }, 3000); + + res.json({ + success: true, + message: `Send to lobby command sent to ${gameRooms.length} game rooms. Rooms will be disposed.` + }); + } catch (error) { + console.error("[AdminAPI] Error sending all to lobby:", error); + res.status(500).json({ error: "Failed to send all players to lobby" }); + } +}); + // SSE endpoint for real-time dashboard updates adminRouter.get("/dashboard-stream", (req: Request, res: Response) => { // Set SSE headers diff --git a/server/src/rooms/GameRoom.ts b/server/src/rooms/GameRoom.ts index e939299..0c76de7 100644 --- a/server/src/rooms/GameRoom.ts +++ b/server/src/rooms/GameRoom.ts @@ -221,18 +221,6 @@ export class GameRoom extends Room { // Removed nextRound handler - rounds now auto-advance - this.onMessage("admin:pause", () => { - this.state.pauseGame(); - }); - - this.onMessage("admin:resume", () => { - this.state.resumeGame(); - }); - - this.onMessage("admin:restart", () => { - this.handleRestart(); - }); - this.onMessage("admin:kick", (client, playerId: string) => { this.handleKick(playerId); }); @@ -480,6 +468,111 @@ export class GameRoom extends Room { } } + private handleSetVariant(variant: string) { + console.log(`[GameRoom] Admin set variant to ${variant} in room ${this.roomId}`); + + this.state.currentVariant = variant; + this.state.currentRound = 1; + this.state.resetRound(); + + if (this.state.gameStatus === GameStatus.FINISHED) { + this.state.gameStatus = GameStatus.PLAYING; + } + + this.setMetadata({ + gameStatus: this.state.gameStatus === GameStatus.WAITING ? 'waiting' : 'playing', + currentRound: this.state.currentRound, + currentVariant: this.state.currentVariant + }); + + if (variant === 'G2') { + this.state.forcedByP2 = true; + } + + this.broadcast("variantChanged", { variant }); + this.sysChat(`馃攧 Admin cambi贸 variante a ${variant}`, 'admin_variant_change'); + + broadcastDashboardUpdate(); + } + + private handleSendToLobby() { + console.log(`[GameRoom] Admin send all players to lobby from room ${this.roomId}`); + + this.sysChat('馃憢 Admin env铆a a todos al lobby', 'admin_send_lobby'); + + // Give players a moment to see the message + setTimeout(() => { + // Disconnect all clients, which will send them back to lobby + this.clients.forEach(client => { + try { + client.leave(1000); + } catch (error) { + console.error(`Failed to disconnect client ${client.sessionId}:`, error); + } + }); + + // Dispose the room + setTimeout(() => { + this.disconnect(); + }, 500); + }, 1000); + } + + // Public method for admin API calls + executeAdminCommand(command: string, ...args: any[]) { + console.log(`[GameRoom] Executing admin command: ${command} with args:`, args); + + switch (command) { + case 'pause': + this.state.pauseGame(); + this.broadcast("gamePaused"); + this.setMetadata({ + gameStatus: 'paused', + currentRound: this.state.currentRound, + currentVariant: this.state.currentVariant + }); + this.sysChat('鈴革笍 Admin paus贸 el juego', 'admin_pause'); + broadcastDashboardUpdate(); + break; + + case 'resume': + this.state.resumeGame(); + this.setMetadata({ + gameStatus: 'playing', + currentRound: this.state.currentRound, + currentVariant: this.state.currentVariant + }); + this.sysChat('鈻讹笍 Admin reanud贸 el juego', 'admin_resume'); + broadcastDashboardUpdate(); + break; + + case 'restart': + this.handleRestart(); + break; + + case 'setVariant': + const variant = args[0]; + if (variant) { + this.handleSetVariant(variant); + } + break; + + case 'sendToLobby': + this.handleSendToLobby(); + break; + + case 'kick': + const playerId = args[0]; + if (playerId) { + this.handleKick(playerId); + } + break; + + default: + console.warn(`[GameRoom] Unknown admin command: ${command}`); + } + } + private getConnectedPlayersCount(): number { let count = 0; this.state.players.forEach(player => {