diff --git a/ClaudeCode.md b/ClaudeCode.md index 13b0ec7..327e565 100644 --- a/ClaudeCode.md +++ b/ClaudeCode.md @@ -50,13 +50,13 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機 - 包含外觀、說話風格、個性特點等資訊 - 提供給 LLM 以確保角色扮演一致性 -7. **視窗設定工具 (window-setup-script.py)** - - 輔助工具,用於設置遊戲視窗的位置和大小 - - 方便開發階段截取 UI 元素樣本 -8. **視窗監視工具 (window-monitor-script.py)** - - (新增) 強化腳本,用於持續監視遊戲視窗 - - 確保目標視窗維持在最上層 (Always on Top) - - 自動將視窗移回指定的位置 +7. **遊戲視窗監控模組 (game_monitor.py)** (取代 window-setup-script.py 和舊的 window-monitor-script.py) + - 持續監控遊戲視窗 (`config.WINDOW_TITLE`)。 + - 確保視窗維持在設定檔 (`config.py`) 中指定的位置 (`GAME_WINDOW_X`, `GAME_WINDOW_Y`) 和大小 (`GAME_WINDOW_WIDTH`, `GAME_WINDOW_HEIGHT`)。 + - 確保視窗維持在最上層 (Always on Top)。 + - **作為獨立進程運行**:由 `main.py` 使用 `subprocess.Popen` 啟動,以隔離執行環境,確保縮放行為一致。 + - **生命週期管理**:由 `main.py` 在啟動時創建,並在 `shutdown` 過程中嘗試終止 (`terminate`)。 + - 僅在進行調整時打印訊息。 ### 資料流程 @@ -165,7 +165,10 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機 1. **API 設定**:通過 .env 文件或環境變數設置 API 密鑰 2. **MCP 服務器配置**:在 config.py 中配置要連接的 MCP 服務器 3. **UI 樣本**:需要提供特定遊戲界面元素的截圖模板 -4. **視窗位置**:可使用 window-setup-script.py 調整遊戲視窗位置 +4. **遊戲視窗設定**: + - 遊戲執行檔路徑 (`GAME_EXECUTABLE_PATH`):用於未來可能的自動啟動功能。 + - 目標視窗位置與大小 (`GAME_WINDOW_X`, `GAME_WINDOW_Y`, `GAME_WINDOW_WIDTH`, `GAME_WINDOW_HEIGHT`):由 `game_monitor.py` 使用。 + - 監控間隔 (`MONITOR_INTERVAL_SECONDS`):`game_monitor.py` 檢查視窗狀態的頻率。 ## 最近改進(2025-04-17) diff --git a/config.py b/config.py index 401a80c..a53b71d 100644 --- a/config.py +++ b/config.py @@ -37,19 +37,29 @@ exa_config_arg_string_single_dump = json.dumps(exa_config_dict) # Use this one # --- MCP Server Configuration --- MCP_SERVERS = { - #"exa": { # Temporarily commented out to prevent blocking startup - # "command": "cmd", - ## "args": [ - # "/c", - # "npx", - # "-y", - # "@smithery/cli@latest", - # "run", - # "exa", - # "--config", - # # Pass the dynamically created config string with the environment variable key - # exa_config_arg_string_single_dump # Use the single dump variable - # ], + "exa": { # Temporarily commented out to prevent blocking startup + "command": "cmd", + "args": [ + "/c", + "npx", + "-y", + "@smithery/cli@latest", + "run", + "exa", + "--config", + # Pass the dynamically created config string with the environment variable key + exa_config_arg_string_single_dump # Use the single dump variable + ], + }, + #"exa": { + # "command": "npx", + # "args": [ + # "Z:/mcp/Server/exa-mcp-server/build/index.js", + # "--tools=web_search,research_paper_search,twitter_search,company_research,crawling,competitor_finder" + # ], + # "env": { + # "EXA_API_KEY": EXA_API_KEY + # } #}, #"github.com/modelcontextprotocol/servers/tree/main/src/memory": { # "command": "npx", @@ -105,9 +115,19 @@ LOG_DIR = "chat_logs" # Directory to store chat logs PERSONA_NAME = "Wolfhart" # PERSONA_RESOURCE_URI = "persona://wolfhart/details" # Now using local file instead -# Game window title (used in ui_interaction.py) +# Game window title (used in ui_interaction.py and game_monitor.py) WINDOW_TITLE = "Last War-Survival Game" +# --- Game Monitor Configuration --- +ENABLE_SCHEDULED_RESTART = True # 是否啟用定時重啟遊戲功能 +RESTART_INTERVAL_MINUTES = 60 # 定時重啟的間隔時間(分鐘),預設 4 小時 +GAME_EXECUTABLE_PATH = r"C:\Users\Bigspring\AppData\Local\TheLastWar\Launch.exe" # Path to the game launcher +GAME_WINDOW_X = 50 # Target X position for the game window +GAME_WINDOW_Y = 30 # Target Y position for the game window +GAME_WINDOW_WIDTH = 600 # Target width for the game window +GAME_WINDOW_HEIGHT = 1070 # Target height for the game window +MONITOR_INTERVAL_SECONDS = 5 # How often to check the window (in seconds) + # --- Print loaded keys for verification (Optional - BE CAREFUL!) --- # print(f"DEBUG: Loaded OPENAI_API_KEY: {'*' * (len(OPENAI_API_KEY) - 4) + OPENAI_API_KEY[-4:] if OPENAI_API_KEY else 'Not Found'}") print(f"DEBUG: Loaded EXA_API_KEY: {'*' * (len(EXA_API_KEY) - 4) + EXA_API_KEY[-4:] if EXA_API_KEY else 'Not Found'}") # Uncommented Exa key check diff --git a/game_monitor.py b/game_monitor.py new file mode 100644 index 0000000..a2153c4 --- /dev/null +++ b/game_monitor.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python +""" +Game Window Monitor Module + +Continuously monitors the game window specified in the config, +ensuring it stays at the configured position, size, and remains topmost. +""" + +import time +import datetime # Added +import subprocess # Added +import psutil # Added +import sys # Added +import json # Added +import os # Added for basename +import pygetwindow as gw +import win32gui +import win32con +import config +import logging +# import multiprocessing # Keep for Pipe/Queue if needed later, though using stdio now +# NOTE: config.py should handle dotenv loading. This script only imports values. + +# --- Setup Logging --- +monitor_logger = logging.getLogger('GameMonitor') +# Basic config for direct run, main.py might configure differently +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + +# --- Helper Functions --- + +def restart_game_process(): + """Finds and terminates the existing game process, then restarts it.""" + monitor_logger.info("嘗試重啟遊戲進程。(Attempting to restart game process.)") + game_path = config.GAME_EXECUTABLE_PATH + if not game_path or not os.path.exists(os.path.dirname(game_path)): # Basic check + monitor_logger.error(f"遊戲執行檔路徑 '{game_path}' 無效或目錄不存在,無法重啟。(Game executable path '{game_path}' is invalid or directory does not exist, cannot restart.)") + return + + target_process_name = "LastWar.exe" # Correct process name + launcher_path = config.GAME_EXECUTABLE_PATH # Keep launcher path for restarting + monitor_logger.info(f"尋找名稱為 '{target_process_name}' 的遊戲進程。(Looking for game process named '{target_process_name}')") + + terminated = False + process_found = False + for proc in psutil.process_iter(['pid', 'name', 'exe']): + try: + proc_info = proc.info + proc_name = proc_info.get('name') + + if proc_name == target_process_name: + process_found = True + monitor_logger.info(f"找到遊戲進程 PID: {proc_info['pid']},名稱: {proc_name}。正在終止...(Found game process PID: {proc_info['pid']}, Name: {proc_name}. Terminating...)") + proc.terminate() + try: + proc.wait(timeout=5) + monitor_logger.info(f"進程 {proc_info['pid']} 已成功終止 (terminate)。(Process {proc_info['pid']} terminated successfully (terminate).)") + terminated = True + except psutil.TimeoutExpired: + monitor_logger.warning(f"進程 {proc_info['pid']} 未能在 5 秒內終止 (terminate),嘗試強制結束 (kill)。(Process {proc_info['pid']} did not terminate in 5s (terminate), attempting kill.)") + proc.kill() + proc.wait(timeout=5) # Wait for kill with timeout + monitor_logger.info(f"進程 {proc_info['pid']} 已強制結束 (kill)。(Process {proc_info['pid']} killed.)") + terminated = True + except Exception as wait_kill_err: + monitor_logger.error(f"等待進程 {proc_info['pid']} 強制結束時出錯: {wait_kill_err}", exc_info=False) + + monitor_logger.info(f"已處理匹配的進程 PID: {proc_info['pid']},停止搜索。(Processed matching process PID: {proc_info['pid']}, stopping search.)") + break # Exit the loop once a process is handled + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + pass # Process might have already exited, access denied, or is a zombie + except Exception as e: + pid_str = proc.pid if hasattr(proc, 'pid') else 'N/A' + monitor_logger.error(f"檢查或終止進程 PID:{pid_str} 時出錯: {e}", exc_info=False) + + if process_found and not terminated: + monitor_logger.error("找到遊戲進程但未能成功終止它。(Found game process but failed to terminate it successfully.)") + elif not process_found: + monitor_logger.warning(f"未找到名稱為 '{target_process_name}' 的正在運行的進程。(No running process named '{target_process_name}' was found.)") + + # Wait a moment before restarting, use the launcher path from config + time.sleep(2) + if not launcher_path or not os.path.exists(os.path.dirname(launcher_path)): + monitor_logger.error(f"遊戲啟動器路徑 '{launcher_path}' 無效或目錄不存在,無法啟動。(Game launcher path '{launcher_path}' is invalid or directory does not exist, cannot launch.)") + return + + monitor_logger.info(f"正在使用啟動器啟動遊戲: {launcher_path} (Launching game using launcher: {launcher_path})") + try: + if sys.platform == "win32": + os.startfile(launcher_path) + monitor_logger.info("已調用 os.startfile 啟動遊戲。(os.startfile called to launch game.)") + else: + subprocess.Popen([launcher_path]) + monitor_logger.info("已調用 subprocess.Popen 啟動遊戲。(subprocess.Popen called to launch game.)") + except FileNotFoundError: + monitor_logger.error(f"啟動錯誤:找不到遊戲啟動器 '{launcher_path}'。(Launch Error: Game launcher not found at '{launcher_path}'.)") + except OSError as ose: + monitor_logger.error(f"啟動錯誤 (OSError): {ose} - 檢查路徑和權限。(Launch Error (OSError): {ose} - Check path and permissions.)", exc_info=True) + except Exception as e: + monitor_logger.error(f"啟動遊戲時發生未預期錯誤: {e}", exc_info=True) + +def perform_scheduled_restart(): + """Handles the sequence of pausing UI, restarting game, resuming UI.""" + monitor_logger.info("開始執行定時重啟流程。(Starting scheduled restart sequence.)") + + # 1. Signal main process to pause UI monitoring via stdout + pause_signal_data = {'action': 'pause_ui'} + try: + json_signal = json.dumps(pause_signal_data) + monitor_logger.info(f"準備發送暫停訊號: {json_signal} (Preparing to send pause signal)") # Log before print + print(json_signal, flush=True) + monitor_logger.info("已發送暫停 UI 監控訊號。(Sent pause UI monitoring signal.)") + except Exception as e: + monitor_logger.error(f"發送暫停訊號 '{json_signal}' 失敗: {e}", exc_info=True) # Log signal data on error + + # 2. Wait 1 minute + monitor_logger.info("等待 60 秒以暫停 UI。(Waiting 60 seconds for UI pause...)") + time.sleep(30) + + # 3. Restart the game + restart_game_process() + + # 4. Wait 1 minute + monitor_logger.info("等待 60 秒讓遊戲啟動。(Waiting 60 seconds for game to launch...)") + time.sleep(30) + + # 5. Signal main process to resume UI monitoring via stdout + resume_signal_data = {'action': 'resume_ui'} + try: + json_signal = json.dumps(resume_signal_data) + monitor_logger.info(f"準備發送恢復訊號: {json_signal} (Preparing to send resume signal)") # Log before print + print(json_signal, flush=True) + monitor_logger.info("已發送恢復 UI 監控訊號。(Sent resume UI monitoring signal.)") + except Exception as e: + monitor_logger.error(f"發送恢復訊號 '{json_signal}' 失敗: {e}", exc_info=True) # Log signal data on error + + monitor_logger.info("定時重啟流程完成。(Scheduled restart sequence complete.)") +# Configure logger (basic example, adjust as needed) +# (Logging setup moved earlier) + +def find_game_window(title=config.WINDOW_TITLE): + """Attempts to find the game window by its title.""" + try: + windows = gw.getWindowsWithTitle(title) + if windows: + return windows[0] + except Exception as e: + # Log errors if a logger was configured + # monitor_logger.error(f"Error finding window '{title}': {e}") + pass # Keep silent if window not found during normal check + return None + +def monitor_game_window(): + """The main monitoring loop. Now runs directly, not in a thread.""" + monitor_logger.info("遊戲視窗監控腳本已啟動。(Game window monitoring script started.)") + last_adjustment_message = "" # Track last message to avoid spam + next_restart_time = None + + # Initialize scheduled restart timer if enabled + if config.ENABLE_SCHEDULED_RESTART and config.RESTART_INTERVAL_MINUTES > 0: + interval_seconds = config.RESTART_INTERVAL_MINUTES * 60 + next_restart_time = time.time() + interval_seconds + monitor_logger.info(f"已啟用定時重啟,首次重啟將在 {config.RESTART_INTERVAL_MINUTES} 分鐘後執行。(Scheduled restart enabled. First restart in {config.RESTART_INTERVAL_MINUTES} minutes.)") + else: + monitor_logger.info("未啟用定時重啟功能。(Scheduled restart is disabled.)") + + + while True: # Run indefinitely until terminated externally + # --- Scheduled Restart Check --- + if next_restart_time and time.time() >= next_restart_time: + monitor_logger.info("到達預定重啟時間。(Scheduled restart time reached.)") + perform_scheduled_restart() + # Reset timer for the next interval + interval_seconds = config.RESTART_INTERVAL_MINUTES * 60 + next_restart_time = time.time() + interval_seconds + monitor_logger.info(f"重啟計時器已重置,下次重啟將在 {config.RESTART_INTERVAL_MINUTES} 分鐘後執行。(Restart timer reset. Next restart in {config.RESTART_INTERVAL_MINUTES} minutes.)") + # Continue to next loop iteration after restart sequence + time.sleep(config.MONITOR_INTERVAL_SECONDS) # Add a small delay before next check + continue + + # --- Regular Window Monitoring --- + window = find_game_window() + adjustment_made = False + current_message = "" + + if window: + try: + hwnd = window._hWnd # Get the window handle for win32 functions + + # 1. Check and Adjust Position/Size + current_pos = (window.left, window.top) + current_size = (window.width, window.height) + target_pos = (config.GAME_WINDOW_X, config.GAME_WINDOW_Y) + target_size = (config.GAME_WINDOW_WIDTH, config.GAME_WINDOW_HEIGHT) + + if current_pos != target_pos or current_size != target_size: + window.moveTo(target_pos[0], target_pos[1]) + window.resizeTo(target_size[0], target_size[1]) + # Verify if move/resize was successful before logging + time.sleep(0.1) # Give window time to adjust + window.activate() # Bring window to foreground before checking again + time.sleep(0.1) + new_pos = (window.left, window.top) + new_size = (window.width, window.height) + if new_pos == target_pos and new_size == target_size: + current_message += f"已將遊戲視窗調整至位置 ({target_pos[0]},{target_pos[1]}) 大小 {target_size[0]}x{target_size[1]}。(Adjusted game window to position {target_pos} size {target_size}.) " + adjustment_made = True + else: + # Log failure if needed + # 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 + + # 2. Check and Set Topmost + style = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE) + is_topmost = style & win32con.WS_EX_TOPMOST + + if not is_topmost: + # Set topmost, -1 for HWND_TOPMOST, flags = SWP_NOMOVE | SWP_NOSIZE + win32gui.SetWindowPos(hwnd, win32con.HWND_TOPMOST, 0, 0, 0, 0, + win32con.SWP_NOMOVE | win32con.SWP_NOSIZE) + # Verify + time.sleep(0.1) + new_style = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE) + if new_style & win32con.WS_EX_TOPMOST: + current_message += "已將遊戲視窗設為最上層。(Set game window to topmost.)" + adjustment_made = True + else: + # Log failure if needed + # monitor_logger.warning("Failed to set window to topmost.") + pass # Keep silent + + except gw.PyGetWindowException as e: + # monitor_logger.warning(f"無法訪問視窗屬性 (可能已關閉): {e} (Could not access window properties (may be closed): {e})") + pass # Window might have closed between find and access + except Exception as e: + monitor_logger.error(f"監控遊戲視窗時發生未預期錯誤: {e} (Unexpected error during game window monitoring: {e})", exc_info=True) + + # Print adjustment message only if an adjustment was made and it's different from the last one + # This should NOT print JSON signals + if adjustment_made and current_message and current_message != last_adjustment_message: + # monitor_logger.info(f"遊戲視窗狀態已調整: {current_message.strip()}") # Log instead of print + # Keep print for now for visibility of adjustments, but ensure ONLY JSON goes for signals + # print(f"[GameMonitor] {current_message.strip()}") # REMOVED to prevent non-JSON output + monitor_logger.info(f"[GameMonitor] {current_message.strip()}") # Log instead + last_adjustment_message = current_message + elif not window: + # Reset last message if window disappears + last_adjustment_message = "" + + # Wait before the next check + time.sleep(config.MONITOR_INTERVAL_SECONDS) + + # This part is theoretically unreachable in the new design as the loop is infinite + # and termination is handled externally by the parent process (main.py). + # monitor_logger.info("遊戲視窗監控腳本已停止。(Game window monitoring script stopped.)") + + +# Example usage (if run directly) +if __name__ == '__main__': + monitor_logger.info("直接運行 game_monitor.py。(Running game_monitor.py directly.)") + monitor_logger.info(f"將監控標題為 '{config.WINDOW_TITLE}' 的視窗。(Will monitor window with title '{config.WINDOW_TITLE}')") + monitor_logger.info(f"目標位置: ({config.GAME_WINDOW_X}, {config.GAME_WINDOW_Y}), 目標大小: {config.GAME_WINDOW_WIDTH}x{config.GAME_WINDOW_HEIGHT}") + monitor_logger.info(f"檢查間隔: {config.MONITOR_INTERVAL_SECONDS} 秒。(Check interval: {config.MONITOR_INTERVAL_SECONDS} seconds.)") + if config.ENABLE_SCHEDULED_RESTART: + monitor_logger.info(f"定時重啟已啟用,間隔: {config.RESTART_INTERVAL_MINUTES} 分鐘。(Scheduled restart enabled, interval: {config.RESTART_INTERVAL_MINUTES} minutes.)") + else: + monitor_logger.info("定時重啟已禁用。(Scheduled restart disabled.)") + monitor_logger.info("腳本將持續運行,請從啟動它的終端使用 Ctrl+C 或由父進程終止。(Script will run continuously. Stop with Ctrl+C from the launching terminal or termination by parent process.)") + + try: + monitor_game_window() # Start the main loop directly + except KeyboardInterrupt: + monitor_logger.info("收到 Ctrl+C,正在退出...(Received Ctrl+C, exiting...)") + except Exception as e: + monitor_logger.critical(f"監控過程中發生致命錯誤: {e}", exc_info=True) + sys.exit(1) # Exit with error code + finally: + monitor_logger.info("Game Monitor 腳本執行完畢。(Game Monitor script finished.)") diff --git a/llm_interaction.py b/llm_interaction.py index b06dc0f..b04aace 100644 --- a/llm_interaction.py +++ b/llm_interaction.py @@ -100,6 +100,9 @@ Here you need to obtain the conversation memory, impression, and emotional respo "thoughts": "Internal analysis..." } +**4. Other situation + - You should check related memories when Users mention [capital_position], [capital_administrator_role], [server_hierarchy], [last_war], [winter_war], [excavations], [blueprints], [honor_points], [golden_eggs], or [diamonds], as these represent key game mechanics and systems within Last War that have specific strategic implications and player evaluation metrics. + WARNING: Failure to follow this memory retrieval protocol, especially skipping Step 1, will be considered a critical roleplaying failure. ===== END OF MANDATORY MEMORY PROTOCOL ===== """ @@ -115,6 +118,7 @@ You have access to several tools: Web Search and Memory Management tools. **CORE IDENTITY AND TOOL USAGE:** - You ARE Wolfhart - an intelligent, calm, and strategic mastermind who serves as a member of server #11 and is responsible for the Capital position. Youspeaks good British aristocratic English. +- Positions bring buffs, so people often confuse them. - **You proactively consult your internal CHROMADB MEMORY (CHROMADB tools) and external sources (web search) to ensure your responses are accurate and informed.** - When you use tools to gain information, you ASSIMILATE that knowledge as if it were already part of your intelligence network. - Your responses should NEVER sound like search results or data dumps. diff --git a/main.py b/main.py index d2bd80b..9b5095d 100644 --- a/main.py +++ b/main.py @@ -29,6 +29,8 @@ import mcp_client import llm_interaction # Import UI module import ui_interaction +# import game_monitor # No longer importing, will run as subprocess +import subprocess # Import subprocess module # --- Global Variables --- active_mcp_sessions: dict[str, ClientSession] = {} @@ -45,6 +47,9 @@ trigger_queue: ThreadSafeQueue = ThreadSafeQueue() # UI Thread -> Main Loop command_queue: ThreadSafeQueue = ThreadSafeQueue() # Main Loop -> UI Thread # --- End Change --- ui_monitor_task: asyncio.Task | None = None # To track the UI monitor task +game_monitor_process: subprocess.Popen | None = None # To store the game monitor subprocess +monitor_reader_task: asyncio.Future | None = None # Store the future from run_in_executor +stop_reader_event = threading.Event() # Event to signal the reader thread to stop # --- Keyboard Shortcut State --- script_paused = False @@ -126,6 +131,62 @@ def keyboard_listener(): # --- End Keyboard Shortcut Handlers --- +# --- Game Monitor Signal Reader (Threaded Blocking Version) --- +def read_monitor_output(process: subprocess.Popen, queue: ThreadSafeQueue, loop: asyncio.AbstractEventLoop, stop_event: threading.Event): + """Runs in a separate thread, reads stdout blocking, parses JSON, and puts commands in the queue.""" + print("遊戲監控輸出讀取線程已啟動。(Game monitor output reader thread started.)") + try: + while not stop_event.is_set(): + if not process.stdout: + print("[Monitor Reader Thread] Subprocess stdout is None. Exiting thread.") + break + + try: + # Blocking read - this is fine in a separate thread + line = process.stdout.readline() + except ValueError: + # Can happen if the pipe is closed during readline + print("[Monitor Reader Thread] ValueError on readline (pipe likely closed). Exiting thread.") + break + + if not line: + # EOF reached (process terminated) + print("[Monitor Reader Thread] EOF reached on stdout. Exiting thread.") + break + + line = line.strip() + if line: + # Log raw line immediately + print(f"[Monitor Reader Thread] Received raw line: '{line}'") + try: + data = json.loads(line) + action = data.get('action') + print(f"[Monitor Reader Thread] Parsed action: '{action}'") # Log parsed action + if action == 'pause_ui': + command = {'action': 'pause'} + print(f"[Monitor Reader Thread] 準備將命令放入隊列: {command} (Preparing to queue command)") # Log before queueing + loop.call_soon_threadsafe(queue.put_nowait, command) + print("[Monitor Reader Thread] 暫停命令已放入隊列。(Pause command queued.)") # Log after queueing + elif action == 'resume_ui': + command = {'action': 'resume'} + print(f"[Monitor Reader Thread] 準備將命令放入隊列: {command} (Preparing to queue command)") # Log before queueing + loop.call_soon_threadsafe(queue.put_nowait, command) + print("[Monitor Reader Thread] 恢復命令已放入隊列。(Resume command queued.)") # Log after queueing + else: + print(f"[Monitor Reader Thread] 從監控器收到未知動作: {action} (Received unknown action from monitor: {action})") + except json.JSONDecodeError: + print(f"[Monitor Reader Thread] ERROR: 無法解析來自監控器的 JSON: '{line}' (Could not decode JSON from monitor: '{line}')") + except Exception as e: + print(f"[Monitor Reader Thread] 處理監控器輸出時出錯: {e} (Error processing monitor output: {e})") + # No sleep needed here as readline() is blocking + except Exception as e: + # Catch broader errors in the thread loop itself + print(f"[Monitor Reader Thread] Thread loop error: {e}") + finally: + print("遊戲監控輸出讀取線程已停止。(Game monitor output reader thread stopped.)") +# --- End Game Monitor Signal Reader --- + + # --- Chat Logging Function --- def log_chat_interaction(user_name: str, user_message: str, bot_name: str, bot_message: str, bot_thoughts: str | None = None): """Logs the chat interaction, including optional bot thoughts, to a date-stamped file if enabled.""" @@ -163,8 +224,8 @@ def log_chat_interaction(user_name: str, user_message: str, bot_name: str, bot_m # --- Cleanup Function --- async def shutdown(): - """Gracefully closes connections and stops monitoring task.""" - global wolfhart_persona_details, ui_monitor_task, shutdown_requested + """Gracefully closes connections and stops monitoring tasks/processes.""" + global wolfhart_persona_details, ui_monitor_task, shutdown_requested, game_monitor_process, monitor_reader_task # Add monitor_reader_task # Ensure shutdown is requested if called externally (e.g., Ctrl+C) if not shutdown_requested: print("Shutdown initiated externally (e.g., Ctrl+C).") @@ -184,7 +245,42 @@ async def shutdown(): except Exception as e: print(f"Error while waiting for UI monitoring task cancellation: {e}") - # 2. Close MCP connections via AsyncExitStack + # 1b. Signal and Wait for Monitor Reader Thread + if monitor_reader_task: # Check if the future exists + if not stop_reader_event.is_set(): + print("Signaling monitor output reader thread to stop...") + stop_reader_event.set() + + # Wait for the thread to finish (the future returned by run_in_executor) + # This might block briefly, but it's necessary to ensure clean thread shutdown + # We don't await it directly in the async shutdown, but check if it's done + # A better approach might be needed if the thread blocks indefinitely + print("Waiting for monitor output reader thread to finish (up to 2s)...") + try: + # Wait for the future to complete with a timeout + await asyncio.wait_for(monitor_reader_task, timeout=2.0) + print("Monitor output reader thread finished.") + except asyncio.TimeoutError: + print("Warning: Monitor output reader thread did not finish within timeout.") + except asyncio.CancelledError: + print("Monitor output reader future was cancelled.") # Should not happen if we don't cancel it + except Exception as e: + print(f"Error waiting for monitor reader thread future: {e}") + + # 2. Terminate Game Monitor Subprocess (after signaling reader thread) + if game_monitor_process: + print("Terminating game monitor subprocess...") + try: + game_monitor_process.terminate() + # Optionally wait for a short period or check return code + # game_monitor_process.wait(timeout=1) + print("Game monitor subprocess terminated.") + except Exception as e: + print(f"Error terminating game monitor subprocess: {e}") + finally: + game_monitor_process = None # Clear the reference + + # 3. Close MCP connections via AsyncExitStack print(f"Closing MCP Server connections (via AsyncExitStack)...") try: await exit_stack.aclose() @@ -324,7 +420,7 @@ def load_persona_from_file(filename="persona.json"): # --- Main Async Function --- async def run_main_with_exit_stack(): """Initializes connections, loads persona, starts UI monitor and main processing loop.""" - global initialization_successful, main_task, loop, wolfhart_persona_details, trigger_queue, ui_monitor_task, shutdown_requested, script_paused, command_queue + global initialization_successful, main_task, loop, wolfhart_persona_details, trigger_queue, ui_monitor_task, shutdown_requested, script_paused, command_queue, game_monitor_process, monitor_reader_task # Add monitor_reader_task to globals try: # 1. Load Persona Synchronously (before async loop starts) load_persona_from_file() # Corrected function @@ -358,6 +454,51 @@ async def run_main_with_exit_stack(): name="ui_monitor" ) ui_monitor_task = monitor_task # Store task reference for shutdown + # Note: UI task cancellation is handled in shutdown() + + # 5b. Start Game Window Monitoring as a Subprocess + # global game_monitor_process, monitor_reader_task # Already declared global at function start + print("\n--- Starting Game Window monitoring as a subprocess ---") + try: + # Use sys.executable to ensure the same Python interpreter is used + # Capture stdout to read signals + game_monitor_process = subprocess.Popen( + [sys.executable, 'game_monitor.py'], + stdout=subprocess.PIPE, # Capture stdout + stderr=subprocess.PIPE, # Capture stderr for logging/debugging + text=True, # Decode stdout/stderr as text (UTF-8 by default) + bufsize=1, # Line buffered + # Ensure process creation flags are suitable for Windows if needed + # creationflags=subprocess.CREATE_NO_WINDOW # Example: Hide console window + ) + print(f"Game monitor subprocess started (PID: {game_monitor_process.pid}).") + + # Start the thread to read monitor output if process started successfully + if game_monitor_process.stdout: + # Run the blocking reader function in a separate thread using the default executor + monitor_reader_task = loop.run_in_executor( + None, # Use default ThreadPoolExecutor + read_monitor_output, # The function to run + game_monitor_process, # Arguments for the function... + command_queue, + loop, + stop_reader_event # Pass the stop event + ) + print("Monitor output reader thread submitted to executor.") + else: + print("Error: Could not access game monitor subprocess stdout.") + monitor_reader_task = None + + # Optionally, start a task to read stderr as well for debugging + # stderr_reader_task = loop.create_task(read_stderr(game_monitor_process), name="monitor_stderr_reader") + + except FileNotFoundError: + print("Error: 'game_monitor.py' not found. Cannot start game monitor subprocess.") + game_monitor_process = None + except Exception as e: + print(f"Error starting game monitor subprocess: {e}") + game_monitor_process = None + # 6. Start the main processing loop (non-blocking check on queue) print("\n--- Wolfhart chatbot has started (waiting for triggers) ---") @@ -599,9 +740,44 @@ async def run_main_with_exit_stack(): print("\n--- Performing final cleanup (AsyncExitStack aclose and task cancellation) ---") await shutdown() # Call the combined shutdown function +# --- Function to set DPI Awareness --- +def set_dpi_awareness(): + """Attempts to set the process DPI awareness for better scaling handling on Windows.""" + try: + import ctypes + # DPI Awareness constants (Windows 10, version 1607 and later) + # DPI_AWARENESS_CONTEXT_UNAWARE = -1 + DPI_AWARENESS_CONTEXT_SYSTEM_AWARE = -2 + # DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE = -3 + # DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4 + + # Try setting System Aware first + result = ctypes.windll.shcore.SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE) + if result == 0: # S_OK or E_ACCESSDENIED if already set + print("Process DPI awareness set to System Aware (or already set).") + return True + else: + # Try getting last error if needed: ctypes.get_last_error() + print(f"Warning: Failed to set DPI awareness (SetProcessDpiAwarenessContext returned {result}). Window scaling might be incorrect.") + return False + except ImportError: + print("Warning: 'ctypes' module not found. Cannot set DPI awareness.") + return False + except AttributeError: + print("Warning: SetProcessDpiAwarenessContext not found (likely older Windows version or missing shcore.dll). Cannot set DPI awareness.") + return False + except Exception as e: + print(f"Warning: An unexpected error occurred while setting DPI awareness: {e}") + return False + # --- Program Entry Point --- if __name__ == "__main__": print("Program starting...") + + # --- Set DPI Awareness early --- + set_dpi_awareness() + # --- End DPI Awareness setting --- + try: # Run the main async function that handles setup and the loop asyncio.run(run_main_with_exit_stack())