Improve game window topmost handling and add forced reconnection for remote control stability

This commit is contained in:
z060142 2025-05-12 23:17:07 +08:00
parent b33ea85768
commit 59471b62ce
3 changed files with 102 additions and 46 deletions

View File

@ -58,7 +58,7 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機
7. **遊戲視窗監控模組 (game_monitor.py)** (取代 window-setup-script.py 和舊的 window-monitor-script.py) 7. **遊戲視窗監控模組 (game_monitor.py)** (取代 window-setup-script.py 和舊的 window-monitor-script.py)
- 持續監控遊戲視窗 (`config.WINDOW_TITLE`)。 - 持續監控遊戲視窗 (`config.WINDOW_TITLE`)。
- 確保視窗維持在設定檔 (`config.py`) 中指定的位置 (`GAME_WINDOW_X`, `GAME_WINDOW_Y`) 和大小 (`GAME_WINDOW_WIDTH`, `GAME_WINDOW_HEIGHT`)。 - 確保視窗維持在設定檔 (`config.py`) 中指定的位置 (`GAME_WINDOW_X`, `GAME_WINDOW_Y`) 和大小 (`GAME_WINDOW_WIDTH`, `GAME_WINDOW_HEIGHT`)。
- 確保視窗維持在最上層 (Always on Top)。 - **確保視窗保持活躍**:如果遊戲視窗不是目前的前景視窗,則嘗試將其帶到前景並啟用 (Bring to Foreground/Activate),取代了之前的強制置頂 (Always on Top) 邏輯 (修改於 2025-05-12)。
- **定時遊戲重啟** (如果 `config.ENABLE_SCHEDULED_RESTART` 為 True) - **定時遊戲重啟** (如果 `config.ENABLE_SCHEDULED_RESTART` 為 True)
- 根據 `config.RESTART_INTERVAL_MINUTES` 設定的間隔執行。 - 根據 `config.RESTART_INTERVAL_MINUTES` 設定的間隔執行。
- **簡化流程 (2025-04-25)** - **簡化流程 (2025-04-25)**
@ -598,6 +598,18 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機
- **依賴項**Windows 上的控制台事件處理仍然依賴 `pywin32` 套件。如果未安裝,程式會打印警告,關閉時的可靠性可能略有降低(但 `stdio_client` 的正常清理機制應在多數情況下仍然有效)。 - **依賴項**Windows 上的控制台事件處理仍然依賴 `pywin32` 套件。如果未安裝,程式會打印警告,關閉時的可靠性可能略有降低(但 `stdio_client` 的正常清理機制應在多數情況下仍然有效)。
- **效果**:恢復了與 `mcp` 庫的兼容性,同時通過標準的上下文管理和輔助性的 Windows 事件處理,實現了在主程式退出時關閉 MCP 伺服器子進程的目標。 - **效果**:恢復了與 `mcp` 庫的兼容性,同時通過標準的上下文管理和輔助性的 Windows 事件處理,實現了在主程式退出時關閉 MCP 伺服器子進程的目標。
## 最近改進2025-05-12
### 遊戲視窗置頂邏輯修改
- **目的**:將 `game_monitor.py` 中強制遊戲視窗「永遠在最上層」(Always on Top) 的行為,修改為「臨時置頂並獲得焦點」(Bring to Foreground/Activate),以解決原方法僅覆蓋其他視窗的問題。
- **`game_monitor.py`**
- 在 `monitor_game_window` 函數的監控循環中,移除了使用 `win32gui.SetWindowPos``win32con.HWND_TOPMOST` 來檢查和設定 `WS_EX_TOPMOST` 樣式的程式碼。
- 替換為檢查當前前景視窗 (`win32gui.GetForegroundWindow()`) 是否為目標遊戲視窗 (`hwnd`)。
- 如果不是,則調用 `win32gui.BringWindowToTop(hwnd)``win32gui.SetForegroundWindow(hwnd)` 來嘗試將遊戲視窗帶到前景並啟用。
- 更新了相關的日誌訊息以反映新的行為。
- **效果**:監控腳本現在會嘗試將失去焦點的遊戲視窗重新激活並帶到前景,而不是強制其覆蓋所有其他視窗。這更符合一般視窗的行為模式。
## 開發建議 ## 開發建議
### 優化方向 ### 優化方向

View File

@ -2443,6 +2443,7 @@ if HAS_SOCKETIO:
self.authenticated = False self.authenticated = False
self.should_exit_flag = threading.Event() # Use an event for thread control self.should_exit_flag = threading.Event() # Use an event for thread control
self.client_thread = None self.client_thread = None
self.last_successful_connection_time = None # Track last successful connection/auth
self.registered_commands = [ self.registered_commands = [
"restart bot", "restart game", "restart all", "restart bot", "restart game", "restart all",
@ -2473,15 +2474,21 @@ if HAS_SOCKETIO:
last_heartbeat = time.time() # For heartbeat last_heartbeat = time.time() # For heartbeat
retry_delay = 1.0 # Start with 1 second delay for exponential backoff retry_delay = 1.0 # Start with 1 second delay for exponential backoff
max_delay = 300.0 # Maximum delay of 5 minutes for exponential backoff max_delay = 300.0 # Maximum delay of 5 minutes for exponential backoff
hourly_refresh_interval = 3600 # 1 hour in seconds
while not self.should_exit_flag.is_set(): while not self.should_exit_flag.is_set():
current_time = time.time() # Get current time at the start of the loop iteration
if not self.sio.connected: if not self.sio.connected:
# Reset connection time tracker when attempting to connect
self.last_successful_connection_time = None
try: try:
logger.info(f"ControlClient: Attempting to connect to {self.server_url}...") logger.info(f"ControlClient: Attempting to connect to {self.server_url}...")
self.sio.connect(self.server_url) self.sio.connect(self.server_url)
logger.info("ControlClient: Successfully connected.") # Connection successful, wait for authentication to set last_successful_connection_time
retry_delay = 1.0 # Reset delay on successful connection logger.info("ControlClient: Successfully established socket connection. Waiting for authentication.")
last_heartbeat = time.time() # Reset heartbeat timer on new connection retry_delay = 1.0 # Reset delay on successful connection attempt
# last_heartbeat = time.time() # Reset heartbeat timer only after authentication? Or here? Let's keep it after auth.
except socketio.exceptions.ConnectionError as e: except socketio.exceptions.ConnectionError as e:
logger.error(f"ControlClient: Connection failed: {e}. Retrying in {retry_delay:.2f}s.") logger.error(f"ControlClient: Connection failed: {e}. Retrying in {retry_delay:.2f}s.")
self.should_exit_flag.wait(retry_delay) self.should_exit_flag.wait(retry_delay)
@ -2496,23 +2503,51 @@ if HAS_SOCKETIO:
retry_delay = max(1.0, retry_delay) # Ensure it's at least 1s retry_delay = max(1.0, retry_delay) # Ensure it's at least 1s
continue continue
# If connected, manage heartbeat and check for exit signal # If connected (socket established, maybe not authenticated yet)
if self.sio.connected: if self.sio.connected:
current_time = time.time() # Check for hourly refresh ONLY if authenticated and timer is set
if current_time - last_heartbeat > 60: # Send heartbeat every 60 seconds if self.authenticated and self.last_successful_connection_time and (current_time - self.last_successful_connection_time > hourly_refresh_interval):
logger.info(f"ControlClient: Hourly session refresh triggered (Connected for > {hourly_refresh_interval}s). Disconnecting for refresh...")
try:
self.sio.disconnect()
# Reset flags immediately after intentional disconnect
self.connected = False
self.authenticated = False
self.last_successful_connection_time = None
logger.info("ControlClient: Disconnected for hourly refresh. Will attempt reconnect in next cycle.")
# Continue to the start of the loop to handle reconnection logic
continue
except Exception as e:
logger.error(f"ControlClient: Error during planned hourly disconnect: {e}")
# Reset flags anyway and let the loop retry
self.connected = False
self.authenticated = False
self.last_successful_connection_time = None
# Manage heartbeat if authenticated
if self.authenticated and current_time - last_heartbeat > 60: # Send heartbeat every 60 seconds
try: try:
self.sio.emit('heartbeat', {'timestamp': current_time}) self.sio.emit('heartbeat', {'timestamp': current_time})
last_heartbeat = current_time last_heartbeat = current_time
logger.debug("ControlClient: Sent heartbeat to keep connection alive.") logger.debug("ControlClient: Sent heartbeat.")
except Exception as e: except Exception as e:
logger.error(f"ControlClient: Error sending heartbeat: {e}. Connection might be lost.") logger.error(f"ControlClient: Error sending heartbeat: {e}. Connection might be lost.")
# Consider triggering disconnect/reconnect logic here if heartbeat fails repeatedly
# Wait before next loop iteration, checking for exit signal
self.should_exit_flag.wait(1) # Check for exit signal every second self.should_exit_flag.wait(1) # Check for exit signal every second
else:
# Fallback if not connected after attempt block (should be rare with current logic) else: # Not connected (e.g., after a disconnect, or failed connection attempt)
logger.debug(f"ControlClient: Not connected (unexpected state in loop), waiting {retry_delay:.2f}s before next cycle.") # This path is hit after disconnects (intentional or unintentional)
# Reset connection time tracker if not already None
if self.last_successful_connection_time is not None:
logger.debug("ControlClient: Resetting connection timer as client is not connected.")
self.last_successful_connection_time = None
logger.debug(f"ControlClient: Not connected, waiting {retry_delay:.2f}s before next connection attempt.")
self.should_exit_flag.wait(retry_delay) self.should_exit_flag.wait(retry_delay)
# Optionally re-calculate retry_delay here if this path is hit, to maintain backoff progression # Exponential backoff for reconnection attempts
retry_delay = min(retry_delay * 2, max_delay) * (0.8 + 0.4 * random.random()) retry_delay = min(retry_delay * 2, max_delay) * (0.8 + 0.4 * random.random())
retry_delay = max(1.0, retry_delay) retry_delay = max(1.0, retry_delay)
@ -2522,6 +2557,7 @@ if HAS_SOCKETIO:
def _on_connect(self): def _on_connect(self):
self.connected = True self.connected = True
# Don't reset timer here, wait for authentication
logger.info("ControlClient: Connected to server. Authenticating...") logger.info("ControlClient: Connected to server. Authenticating...")
self.sio.emit('authenticate', { self.sio.emit('authenticate', {
'type': 'client', 'type': 'client',
@ -2530,26 +2566,30 @@ if HAS_SOCKETIO:
}) })
def _on_disconnect(self): def _on_disconnect(self):
was_connected = self.connected # Store previous state
self.connected = False self.connected = False
self.authenticated = False self.authenticated = False
logger.info("ControlClient: Disconnected from server.") self.last_successful_connection_time = None # Reset timer on any disconnect
if was_connected: # Only log if it was previously connected
logger.info("ControlClient: Disconnected from server.")
else:
logger.debug("ControlClient: Received disconnect event, but was already marked as disconnected.")
# Force reconnection if not intentionally stopping # Remove the immediate reconnection attempt here, let _run_forever handle it with backoff
if not self.should_exit_flag.is_set(): # if not self.should_exit_flag.is_set():
logger.info("ControlClient: Attempting immediate reconnection from _on_disconnect...") # logger.info("ControlClient: Disconnected. Reconnection will be handled by the main loop.")
try:
# This is an immediate attempt; _run_forever handles sustained retries.
if not self.sio.connected: # Check before trying to connect
self.sio.connect(self.server_url)
except Exception as e:
logger.error(f"ControlClient: Immediate reconnection from _on_disconnect failed: {e}")
def _on_authenticated(self, data): def _on_authenticated(self, data):
if data.get('success'): if data.get('success'):
self.authenticated = True self.authenticated = True
logger.info("ControlClient: Authentication successful.") self.last_successful_connection_time = time.time() # Start timer on successful auth
# Reset heartbeat timer upon successful authentication
# Find where last_heartbeat is accessible or make it accessible (e.g., self.last_heartbeat)
# For now, assume last_heartbeat is managed within _run_forever and will naturally reset timing
logger.info("ControlClient: Authentication successful. Hourly refresh timer started.")
else: else:
self.authenticated = False self.authenticated = False
self.last_successful_connection_time = None # Ensure timer is reset if auth fails
logger.error(f"ControlClient: Authentication failed: {data.get('error', 'Unknown error')}") logger.error(f"ControlClient: Authentication failed: {data.get('error', 'Unknown error')}")
self.sio.disconnect() # Disconnect if auth fails self.sio.disconnect() # Disconnect if auth fails

View File

@ -217,24 +217,28 @@ def monitor_game_window():
# monitor_logger.warning(f"Failed to adjust window. Current: {new_pos} {new_size}, Target: {target_pos} {target_size}") # monitor_logger.warning(f"Failed to adjust window. Current: {new_pos} {new_size}, Target: {target_pos} {target_size}")
pass # Keep silent on failure for now pass # Keep silent on failure for now
# 2. Check and Set Topmost # 2. Check and Bring to Foreground/Activate
style = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE) current_foreground_hwnd = win32gui.GetForegroundWindow()
is_topmost = style & win32con.WS_EX_TOPMOST
if not is_topmost: if current_foreground_hwnd != hwnd:
# Set topmost, -1 for HWND_TOPMOST, flags = SWP_NOMOVE | SWP_NOSIZE try:
win32gui.SetWindowPos(hwnd, win32con.HWND_TOPMOST, 0, 0, 0, 0, # Attempt to bring to top and set foreground
win32con.SWP_NOMOVE | win32con.SWP_NOSIZE) # Note: SetForegroundWindow might fail if the calling process doesn't have foreground rights
# Verify win32gui.BringWindowToTop(hwnd)
time.sleep(0.1) win32gui.SetForegroundWindow(hwnd)
new_style = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE) # Short delay to allow window manager to process
if new_style & win32con.WS_EX_TOPMOST: time.sleep(0.1)
current_message += "已將遊戲視窗設為最上層。(Set game window to topmost.)" # Verify if it became the foreground window
adjustment_made = True if win32gui.GetForegroundWindow() == hwnd:
else: current_message += "已將遊戲視窗帶到前景並啟用。(Brought game window to foreground and activated.) "
# Log failure if needed adjustment_made = True
# monitor_logger.warning("Failed to set window to topmost.") else:
pass # Keep silent # Optional: Log if setting foreground failed, might happen if another app steals focus quickly
# monitor_logger.warning("嘗試將視窗設為前景後,它並未成為前景視窗。(Attempted to set window foreground, but it did not become the foreground window.)")
pass
except Exception as fg_err:
# This can happen if the window handle is invalid or other win32 errors occur
monitor_logger.warning(f"嘗試將視窗設為前景時出錯: {fg_err} (Error trying to set window foreground: {fg_err})")
except gw.PyGetWindowException as e: except gw.PyGetWindowException as e:
# Log PyGetWindowException specifically, might indicate window closed during check # Log PyGetWindowException specifically, might indicate window closed during check