diff --git a/nexus/morrowind_harness.py b/nexus/morrowind_harness.py new file mode 100644 index 00000000..117535ac --- /dev/null +++ b/nexus/morrowind_harness.py @@ -0,0 +1,888 @@ +#!/usr/bin/env python3 +""" +Morrowind/OpenMW MCP Harness — GamePortal Protocol Implementation + +A harness for The Elder Scrolls III: Morrowind (via OpenMW) using MCP servers: +- desktop-control MCP: screenshots, mouse/keyboard input +- steam-info MCP: game stats, achievements, player count + +This harness implements the GamePortal Protocol: + capture_state() → GameState + execute_action(action) → ActionResult + +The ODA (Observe-Decide-Act) loop connects perception to action through +Hermes WebSocket telemetry. + +World-state verification uses screenshots + position inference rather than +log-only proof, per issue #673 acceptance criteria. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import subprocess +import time +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Optional + +import websockets + +# ═══════════════════════════════════════════════════════════════════════════ +# CONFIGURATION +# ═══════════════════════════════════════════════════════════════════════════ + +MORROWIND_APP_ID = 22320 +MORROWIND_WINDOW_TITLE = "OpenMW" +DEFAULT_HERMES_WS_URL = "ws://localhost:8000/ws" +DEFAULT_MCP_DESKTOP_COMMAND = ["npx", "-y", "@modelcontextprotocol/server-desktop-control"] +DEFAULT_MCP_STEAM_COMMAND = ["npx", "-y", "@modelcontextprotocol/server-steam-info"] + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [morrowind] %(message)s", + datefmt="%H:%M:%S", +) +log = logging.getLogger("morrowind") + + +# ═══════════════════════════════════════════════════════════════════════════ +# MCP CLIENT — JSON-RPC over stdio +# ═══════════════════════════════════════════════════════════════════════════ + +class MCPClient: + """Client for MCP servers communicating over stdio.""" + + def __init__(self, name: str, command: list[str]): + self.name = name + self.command = command + self.process: Optional[subprocess.Popen] = None + self.request_id = 0 + self._lock = asyncio.Lock() + + async def start(self) -> bool: + """Start the MCP server process.""" + try: + self.process = subprocess.Popen( + self.command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + await asyncio.sleep(0.5) + if self.process.poll() is not None: + log.error(f"MCP server {self.name} exited immediately") + return False + log.info(f"MCP server {self.name} started (PID: {self.process.pid})") + return True + except Exception as e: + log.error(f"Failed to start MCP server {self.name}: {e}") + return False + + def stop(self): + """Stop the MCP server process.""" + if self.process and self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=2) + except subprocess.TimeoutExpired: + self.process.kill() + log.info(f"MCP server {self.name} stopped") + + async def call_tool(self, tool_name: str, arguments: dict) -> dict: + """Call an MCP tool and return the result.""" + async with self._lock: + self.request_id += 1 + request = { + "jsonrpc": "2.0", + "id": self.request_id, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments, + }, + } + + if not self.process or self.process.poll() is not None: + return {"error": "MCP server not running"} + + try: + request_line = json.dumps(request) + "\n" + self.process.stdin.write(request_line) + self.process.stdin.flush() + + response_line = await asyncio.wait_for( + asyncio.to_thread(self.process.stdout.readline), + timeout=10.0, + ) + + if not response_line: + return {"error": "Empty response from MCP server"} + + response = json.loads(response_line) + return response.get("result", {}).get("content", [{}])[0].get("text", "") + + except asyncio.TimeoutError: + return {"error": f"Timeout calling {tool_name}"} + except json.JSONDecodeError as e: + return {"error": f"Invalid JSON response: {e}"} + except Exception as e: + return {"error": str(e)} + + +# ═══════════════════════════════════════════════════════════════════════════ +# GAME STATE DATA CLASSES +# ═══════════════════════════════════════════════════════════════════════════ + +@dataclass +class VisualState: + """Visual perception from the game.""" + screenshot_path: Optional[str] = None + screen_size: tuple[int, int] = (1920, 1080) + mouse_position: tuple[int, int] = (0, 0) + window_found: bool = False + window_title: str = "" + + +@dataclass +class GameContext: + """Game-specific context from Steam.""" + app_id: int = MORROWIND_APP_ID + playtime_hours: float = 0.0 + achievements_unlocked: int = 0 + achievements_total: int = 0 + current_players_online: int = 0 + game_name: str = "The Elder Scrolls III: Morrowind" + is_running: bool = False + + +@dataclass +class WorldState: + """Morrowind-specific world-state derived from perception.""" + estimated_location: str = "unknown" + is_in_menu: bool = False + is_in_dialogue: bool = False + is_in_combat: bool = False + time_of_day: str = "unknown" + health_status: str = "unknown" + + +@dataclass +class GameState: + """Complete game state per GamePortal Protocol.""" + portal_id: str = "morrowind" + timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + visual: VisualState = field(default_factory=VisualState) + game_context: GameContext = field(default_factory=GameContext) + world_state: WorldState = field(default_factory=WorldState) + session_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8]) + + def to_dict(self) -> dict: + return { + "portal_id": self.portal_id, + "timestamp": self.timestamp, + "session_id": self.session_id, + "visual": { + "screenshot_path": self.visual.screenshot_path, + "screen_size": list(self.visual.screen_size), + "mouse_position": list(self.visual.mouse_position), + "window_found": self.visual.window_found, + "window_title": self.visual.window_title, + }, + "game_context": { + "app_id": self.game_context.app_id, + "playtime_hours": self.game_context.playtime_hours, + "achievements_unlocked": self.game_context.achievements_unlocked, + "achievements_total": self.game_context.achievements_total, + "current_players_online": self.game_context.current_players_online, + "game_name": self.game_context.game_name, + "is_running": self.game_context.is_running, + }, + "world_state": { + "estimated_location": self.world_state.estimated_location, + "is_in_menu": self.world_state.is_in_menu, + "is_in_dialogue": self.world_state.is_in_dialogue, + "is_in_combat": self.world_state.is_in_combat, + "time_of_day": self.world_state.time_of_day, + "health_status": self.world_state.health_status, + }, + } + + +@dataclass +class ActionResult: + """Result of executing an action.""" + success: bool = False + action: str = "" + params: dict = field(default_factory=dict) + timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + error: Optional[str] = None + + def to_dict(self) -> dict: + result = { + "success": self.success, + "action": self.action, + "params": self.params, + "timestamp": self.timestamp, + } + if self.error: + result["error"] = self.error + return result + + +# ═══════════════════════════════════════════════════════════════════════════ +# MORROWIND HARNESS — Main Implementation +# ═══════════════════════════════════════════════════════════════════════════ + +class MorrowindHarness: + """ + Harness for The Elder Scrolls III: Morrowind (OpenMW). + + Implements the GamePortal Protocol: + - capture_state(): Takes screenshot, gets screen info, fetches Steam stats + - execute_action(): Translates actions to MCP tool calls + + World-state verification (issue #673): uses screenshot evidence per cycle, + not just log assertions. + """ + + def __init__( + self, + hermes_ws_url: str = DEFAULT_HERMES_WS_URL, + desktop_command: Optional[list[str]] = None, + steam_command: Optional[list[str]] = None, + enable_mock: bool = False, + ): + self.hermes_ws_url = hermes_ws_url + self.desktop_command = desktop_command or DEFAULT_MCP_DESKTOP_COMMAND + self.steam_command = steam_command or DEFAULT_MCP_STEAM_COMMAND + self.enable_mock = enable_mock + + # MCP clients + self.desktop_mcp: Optional[MCPClient] = None + self.steam_mcp: Optional[MCPClient] = None + + # WebSocket connection to Hermes + self.ws: Optional[websockets.WebSocketClientProtocol] = None + self.ws_connected = False + + # State + self.session_id = str(uuid.uuid4())[:8] + self.cycle_count = 0 + self.running = False + + # Trace storage + self.trace_dir = Path.home() / ".timmy" / "traces" / "morrowind" + self.trace_file: Optional[Path] = None + self.trace_cycles: list[dict] = [] + + # ═══ LIFECYCLE ═══ + + async def start(self) -> bool: + """Initialize MCP servers and WebSocket connection.""" + log.info("=" * 50) + log.info("MORROWIND HARNESS — INITIALIZING") + log.info(f" Session: {self.session_id}") + log.info(f" Hermes WS: {self.hermes_ws_url}") + log.info("=" * 50) + + if not self.enable_mock: + self.desktop_mcp = MCPClient("desktop-control", self.desktop_command) + self.steam_mcp = MCPClient("steam-info", self.steam_command) + + desktop_ok = await self.desktop_mcp.start() + steam_ok = await self.steam_mcp.start() + + if not desktop_ok: + log.warning("Desktop MCP failed to start, enabling mock mode") + self.enable_mock = True + + if not steam_ok: + log.warning("Steam MCP failed to start, will use fallback stats") + else: + log.info("Running in MOCK mode — no actual MCP servers") + + await self._connect_hermes() + + # Init trace + self.trace_dir.mkdir(parents=True, exist_ok=True) + trace_id = f"mw_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + self.trace_file = self.trace_dir / f"trace_{trace_id}.jsonl" + + log.info("Harness initialized successfully") + return True + + async def stop(self): + """Shutdown MCP servers and disconnect.""" + self.running = False + log.info("Shutting down harness...") + + if self.desktop_mcp: + self.desktop_mcp.stop() + if self.steam_mcp: + self.steam_mcp.stop() + + if self.ws: + await self.ws.close() + self.ws_connected = False + + # Write manifest + if self.trace_file and self.trace_cycles: + manifest_file = self.trace_file.with_name( + self.trace_file.name.replace("trace_", "manifest_").replace(".jsonl", ".json") + ) + manifest = { + "session_id": self.session_id, + "game": "The Elder Scrolls III: Morrowind", + "app_id": MORROWIND_APP_ID, + "total_cycles": len(self.trace_cycles), + "trace_file": str(self.trace_file), + "started_at": self.trace_cycles[0].get("timestamp", "") if self.trace_cycles else "", + "finished_at": datetime.now(timezone.utc).isoformat(), + } + with open(manifest_file, "w") as f: + json.dump(manifest, f, indent=2) + log.info(f"Trace saved: {self.trace_file}") + log.info(f"Manifest: {manifest_file}") + + log.info("Harness shutdown complete") + + async def _connect_hermes(self): + """Connect to Hermes WebSocket for telemetry.""" + try: + self.ws = await websockets.connect(self.hermes_ws_url) + self.ws_connected = True + log.info(f"Connected to Hermes: {self.hermes_ws_url}") + + await self._send_telemetry({ + "type": "harness_register", + "harness_id": "morrowind", + "session_id": self.session_id, + "game": "The Elder Scrolls III: Morrowind", + "app_id": MORROWIND_APP_ID, + }) + except Exception as e: + log.warning(f"Could not connect to Hermes: {e}") + self.ws_connected = False + + async def _send_telemetry(self, data: dict): + """Send telemetry data to Hermes WebSocket.""" + if self.ws_connected and self.ws: + try: + await self.ws.send(json.dumps(data)) + except Exception as e: + log.warning(f"Telemetry send failed: {e}") + self.ws_connected = False + + # ═══ GAMEPORTAL PROTOCOL: capture_state() ═══ + + async def capture_state(self) -> GameState: + """ + Capture current game state. + + Returns GameState with: + - Screenshot of OpenMW window + - Screen dimensions and mouse position + - Steam stats (playtime, achievements, player count) + - World-state inference from visual evidence + """ + state = GameState(session_id=self.session_id) + + visual = await self._capture_visual_state() + state.visual = visual + + context = await self._capture_game_context() + state.game_context = context + + # Derive world-state from visual evidence (not just logs) + state.world_state = self._infer_world_state(visual) + + await self._send_telemetry({ + "type": "game_state_captured", + "portal_id": "morrowind", + "session_id": self.session_id, + "cycle": self.cycle_count, + "visual": { + "window_found": visual.window_found, + "screenshot_path": visual.screenshot_path, + "screen_size": list(visual.screen_size), + }, + "world_state": { + "estimated_location": state.world_state.estimated_location, + "is_in_menu": state.world_state.is_in_menu, + }, + }) + + return state + + def _infer_world_state(self, visual: VisualState) -> WorldState: + """ + Infer world-state from visual evidence. + + In production, this would use a vision model to analyze the screenshot. + For the deterministic pilot loop, we record the screenshot as proof. + """ + ws = WorldState() + + if not visual.window_found: + ws.estimated_location = "window_not_found" + return ws + + # Placeholder inference — real version uses vision model + # The screenshot IS the world-state proof (issue #673 acceptance #3) + ws.estimated_location = "vvardenfell" + ws.time_of_day = "unknown" # Would parse from HUD + ws.health_status = "unknown" # Would parse from HUD + + return ws + + async def _capture_visual_state(self) -> VisualState: + """Capture visual state via desktop-control MCP.""" + visual = VisualState() + + if self.enable_mock or not self.desktop_mcp: + visual.screenshot_path = f"/tmp/morrowind_mock_{int(time.time())}.png" + visual.screen_size = (1920, 1080) + visual.mouse_position = (960, 540) + visual.window_found = True + visual.window_title = MORROWIND_WINDOW_TITLE + return visual + + try: + size_result = await self.desktop_mcp.call_tool("get_screen_size", {}) + if isinstance(size_result, str): + parts = size_result.lower().replace("x", " ").split() + if len(parts) >= 2: + visual.screen_size = (int(parts[0]), int(parts[1])) + + mouse_result = await self.desktop_mcp.call_tool("get_mouse_position", {}) + if isinstance(mouse_result, str): + parts = mouse_result.replace(",", " ").split() + if len(parts) >= 2: + visual.mouse_position = (int(parts[0]), int(parts[1])) + + screenshot_path = f"/tmp/morrowind_capture_{int(time.time())}.png" + screenshot_result = await self.desktop_mcp.call_tool( + "take_screenshot", + {"path": screenshot_path, "window_title": MORROWIND_WINDOW_TITLE} + ) + + if screenshot_result and "error" not in str(screenshot_result): + visual.screenshot_path = screenshot_path + visual.window_found = True + visual.window_title = MORROWIND_WINDOW_TITLE + else: + screenshot_result = await self.desktop_mcp.call_tool( + "take_screenshot", + {"path": screenshot_path} + ) + if screenshot_result and "error" not in str(screenshot_result): + visual.screenshot_path = screenshot_path + visual.window_found = True + + except Exception as e: + log.warning(f"Visual capture failed: {e}") + visual.window_found = False + + return visual + + async def _capture_game_context(self) -> GameContext: + """Capture game context via steam-info MCP.""" + context = GameContext() + + if self.enable_mock or not self.steam_mcp: + context.playtime_hours = 87.3 + context.achievements_unlocked = 12 + context.achievements_total = 30 + context.current_players_online = 523 + context.is_running = True + return context + + try: + players_result = await self.steam_mcp.call_tool( + "steam-current-players", + {"app_id": MORROWIND_APP_ID} + ) + if isinstance(players_result, (int, float)): + context.current_players_online = int(players_result) + elif isinstance(players_result, str): + digits = "".join(c for c in players_result if c.isdigit()) + if digits: + context.current_players_online = int(digits) + + context.playtime_hours = 0.0 + context.achievements_unlocked = 0 + context.achievements_total = 0 + + except Exception as e: + log.warning(f"Game context capture failed: {e}") + + return context + + # ═══ GAMEPORTAL PROTOCOL: execute_action() ═══ + + async def execute_action(self, action: dict) -> ActionResult: + """ + Execute an action in the game. + + Supported actions: + - click: { "type": "click", "x": int, "y": int } + - right_click: { "type": "right_click", "x": int, "y": int } + - move_to: { "type": "move_to", "x": int, "y": int } + - press_key: { "type": "press_key", "key": str } + - hotkey: { "type": "hotkey", "keys": str } + - type_text: { "type": "type_text", "text": str } + + Morrowind-specific shortcuts: + - inventory: press_key("Tab") + - journal: press_key("j") + - rest: press_key("t") + - activate: press_key("space") or press_key("e") + """ + action_type = action.get("type", "") + result = ActionResult(action=action_type, params=action) + + if self.enable_mock or not self.desktop_mcp: + log.info(f"[MOCK] Action: {action_type} with params: {action}") + result.success = True + await self._send_telemetry({ + "type": "action_executed", + "action": action_type, + "params": action, + "success": True, + "mock": True, + }) + return result + + try: + success = False + + if action_type == "click": + success = await self._mcp_click(action.get("x", 0), action.get("y", 0)) + elif action_type == "right_click": + success = await self._mcp_right_click(action.get("x", 0), action.get("y", 0)) + elif action_type == "move_to": + success = await self._mcp_move_to(action.get("x", 0), action.get("y", 0)) + elif action_type == "press_key": + success = await self._mcp_press_key(action.get("key", "")) + elif action_type == "hotkey": + success = await self._mcp_hotkey(action.get("keys", "")) + elif action_type == "type_text": + success = await self._mcp_type_text(action.get("text", "")) + elif action_type == "scroll": + success = await self._mcp_scroll(action.get("amount", 0)) + else: + result.error = f"Unknown action type: {action_type}" + + result.success = success + if not success and not result.error: + result.error = "MCP tool call failed" + + except Exception as e: + result.success = False + result.error = str(e) + log.error(f"Action execution failed: {e}") + + await self._send_telemetry({ + "type": "action_executed", + "action": action_type, + "params": action, + "success": result.success, + "error": result.error, + }) + + return result + + # ═══ MCP TOOL WRAPPERS ═══ + + async def _mcp_click(self, x: int, y: int) -> bool: + result = await self.desktop_mcp.call_tool("click", {"x": x, "y": y}) + return "error" not in str(result).lower() + + async def _mcp_right_click(self, x: int, y: int) -> bool: + result = await self.desktop_mcp.call_tool("right_click", {"x": x, "y": y}) + return "error" not in str(result).lower() + + async def _mcp_move_to(self, x: int, y: int) -> bool: + result = await self.desktop_mcp.call_tool("move_to", {"x": x, "y": y}) + return "error" not in str(result).lower() + + async def _mcp_press_key(self, key: str) -> bool: + result = await self.desktop_mcp.call_tool("press_key", {"key": key}) + return "error" not in str(result).lower() + + async def _mcp_hotkey(self, keys: str) -> bool: + result = await self.desktop_mcp.call_tool("hotkey", {"keys": keys}) + return "error" not in str(result).lower() + + async def _mcp_type_text(self, text: str) -> bool: + result = await self.desktop_mcp.call_tool("type_text", {"text": text}) + return "error" not in str(result).lower() + + async def _mcp_scroll(self, amount: int) -> bool: + result = await self.desktop_mcp.call_tool("scroll", {"amount": amount}) + return "error" not in str(result).lower() + + # ═══ MORROWIND-SPECIFIC ACTIONS ═══ + + async def open_inventory(self) -> ActionResult: + """Open inventory screen (Tab key).""" + return await self.execute_action({"type": "press_key", "key": "Tab"}) + + async def open_journal(self) -> ActionResult: + """Open journal (J key).""" + return await self.execute_action({"type": "press_key", "key": "j"}) + + async def rest(self) -> ActionResult: + """Rest/wait (T key).""" + return await self.execute_action({"type": "press_key", "key": "t"}) + + async def activate(self) -> ActionResult: + """Activate/interact with object or NPC (Space key).""" + return await self.execute_action({"type": "press_key", "key": "space"}) + + async def move_forward(self, duration: float = 0.5) -> ActionResult: + """Move forward (W key held).""" + # Note: desktop-control MCP may not support hold; use press as proxy + return await self.execute_action({"type": "press_key", "key": "w"}) + + async def move_backward(self) -> ActionResult: + """Move backward (S key).""" + return await self.execute_action({"type": "press_key", "key": "s"}) + + async def strafe_left(self) -> ActionResult: + """Strafe left (A key).""" + return await self.execute_action({"type": "press_key", "key": "a"}) + + async def strafe_right(self) -> ActionResult: + """Strafe right (D key).""" + return await self.execute_action({"type": "press_key", "key": "d"}) + + async def attack(self) -> ActionResult: + """Attack (left click).""" + screen_w, screen_h = (1920, 1080) + return await self.execute_action({"type": "click", "x": screen_w // 2, "y": screen_h // 2}) + + # ═══ ODA LOOP (Observe-Decide-Act) ═══ + + async def run_pilot_loop( + self, + decision_fn: Callable[[GameState], list[dict]], + max_iterations: int = 3, + iteration_delay: float = 2.0, + ) -> list[dict]: + """ + Deterministic pilot loop — issue #673. + + Runs perceive → decide → act cycles with world-state proof. + Each cycle captures a screenshot as evidence of the game state. + + Returns list of cycle traces for verification. + """ + log.info("=" * 50) + log.info("MORROWIND PILOT LOOP — STARTING") + log.info(f" Max iterations: {max_iterations}") + log.info(f" Iteration delay: {iteration_delay}s") + log.info("=" * 50) + + self.running = True + cycle_traces = [] + + for iteration in range(max_iterations): + if not self.running: + break + + self.cycle_count = iteration + cycle_trace = { + "cycle_index": iteration, + "timestamp": datetime.now(timezone.utc).isoformat(), + "session_id": self.session_id, + } + + log.info(f"\n--- Pilot Cycle {iteration + 1}/{max_iterations} ---") + + # 1. PERCEIVE: Capture state (includes world-state proof via screenshot) + log.info("[PERCEIVE] Capturing game state...") + state = await self.capture_state() + log.info(f" Screenshot: {state.visual.screenshot_path}") + log.info(f" Window found: {state.visual.window_found}") + log.info(f" Location: {state.world_state.estimated_location}") + + cycle_trace["perceive"] = { + "screenshot_path": state.visual.screenshot_path, + "window_found": state.visual.window_found, + "screen_size": list(state.visual.screen_size), + "world_state": state.to_dict()["world_state"], + } + + # 2. DECIDE: Get actions from decision function + log.info("[DECIDE] Getting actions...") + actions = decision_fn(state) + log.info(f" Decision returned {len(actions)} actions") + + cycle_trace["decide"] = { + "actions_planned": actions, + } + + # 3. ACT: Execute actions + log.info("[ACT] Executing actions...") + results = [] + for i, action in enumerate(actions): + log.info(f" Action {i+1}/{len(actions)}: {action.get('type', 'unknown')}") + result = await self.execute_action(action) + results.append(result) + log.info(f" Result: {'SUCCESS' if result.success else 'FAILED'}") + if result.error: + log.info(f" Error: {result.error}") + + cycle_trace["act"] = { + "actions_executed": [r.to_dict() for r in results], + "succeeded": sum(1 for r in results if r.success), + "failed": sum(1 for r in results if not r.success), + } + + # Persist cycle trace to JSONL + cycle_traces.append(cycle_trace) + if self.trace_file: + with open(self.trace_file, "a") as f: + f.write(json.dumps(cycle_trace) + "\n") + + # Send cycle summary telemetry + await self._send_telemetry({ + "type": "pilot_cycle_complete", + "cycle": iteration, + "actions_executed": len(actions), + "successful": sum(1 for r in results if r.success), + "world_state_proof": state.visual.screenshot_path, + }) + + if iteration < max_iterations - 1: + await asyncio.sleep(iteration_delay) + + log.info("\n" + "=" * 50) + log.info("PILOT LOOP COMPLETE") + log.info(f"Total cycles: {len(cycle_traces)}") + log.info("=" * 50) + + return cycle_traces + + +# ═══════════════════════════════════════════════════════════════════════════ +# SIMPLE DECISION FUNCTIONS +# ═══════════════════════════════════════════════════════════════════════════ + +def simple_test_decision(state: GameState) -> list[dict]: + """ + A simple decision function for testing the pilot loop. + + Moves to center of screen, then presses space to interact. + """ + actions = [] + + if state.visual.window_found: + center_x = state.visual.screen_size[0] // 2 + center_y = state.visual.screen_size[1] // 2 + actions.append({"type": "move_to", "x": center_x, "y": center_y}) + + actions.append({"type": "press_key", "key": "space"}) + + return actions + + +def morrowind_explore_decision(state: GameState) -> list[dict]: + """ + Example decision function for Morrowind exploration. + + Would be replaced by a vision-language model that analyzes screenshots. + """ + actions = [] + + screen_w, screen_h = state.visual.screen_size + + # Move forward + actions.append({"type": "press_key", "key": "w"}) + + # Look around (move mouse to different positions) + actions.append({"type": "move_to", "x": int(screen_w * 0.3), "y": int(screen_h * 0.5)}) + + return actions + + +# ═══════════════════════════════════════════════════════════════════════════ +# CLI ENTRYPOINT +# ═══════════════════════════════════════════════════════════════════════════ + +async def main(): + """ + Test the Morrowind harness with the deterministic pilot loop. + + Usage: + python morrowind_harness.py [--mock] [--iterations N] + """ + import argparse + + parser = argparse.ArgumentParser( + description="Morrowind/OpenMW MCP Harness — Deterministic Pilot Loop (issue #673)" + ) + parser.add_argument( + "--mock", + action="store_true", + help="Run in mock mode (no actual MCP servers)", + ) + parser.add_argument( + "--hermes-ws", + default=DEFAULT_HERMES_WS_URL, + help=f"Hermes WebSocket URL (default: {DEFAULT_HERMES_WS_URL})", + ) + parser.add_argument( + "--iterations", + type=int, + default=3, + help="Number of pilot loop iterations (default: 3)", + ) + parser.add_argument( + "--delay", + type=float, + default=1.0, + help="Delay between iterations in seconds (default: 1.0)", + ) + args = parser.parse_args() + + harness = MorrowindHarness( + hermes_ws_url=args.hermes_ws, + enable_mock=args.mock, + ) + + try: + await harness.start() + + # Run deterministic pilot loop with world-state proof + traces = await harness.run_pilot_loop( + decision_fn=simple_test_decision, + max_iterations=args.iterations, + iteration_delay=args.delay, + ) + + # Print verification summary + log.info("\n--- Verification Summary ---") + log.info(f"Cycles completed: {len(traces)}") + for t in traces: + screenshot = t.get("perceive", {}).get("screenshot_path", "none") + actions = len(t.get("decide", {}).get("actions_planned", [])) + succeeded = t.get("act", {}).get("succeeded", 0) + log.info(f" Cycle {t['cycle_index']}: screenshot={screenshot}, actions={actions}, ok={succeeded}") + + except KeyboardInterrupt: + log.info("Interrupted by user") + finally: + await harness.stop() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/portals.json b/portals.json index b870c62d..b0ca0342 100644 --- a/portals.json +++ b/portals.json @@ -7,9 +7,26 @@ "color": "#ff6600", "position": { "x": 15, "y": 0, "z": -10 }, "rotation": { "y": -0.5 }, + "portal_type": "game-world", + "world_category": "rpg", + "environment": "local", + "access_mode": "operator", + "readiness_state": "prototype", + "readiness_steps": { + "prototype": { "label": "Prototype", "done": true }, + "runtime_ready": { "label": "Runtime Ready", "done": false }, + "launched": { "label": "Launched", "done": false }, + "harness_bridged": { "label": "Harness Bridged", "done": false } + }, + "blocked_reason": null, + "telemetry_source": "hermes-harness:morrowind", + "owner": "Timmy", + "app_id": 22320, + "window_title": "OpenMW", "destination": { - "url": "https://morrowind.timmy.foundation", + "url": null, "type": "harness", + "action_label": "Enter Vvardenfell", "params": { "world": "vvardenfell" } } },