#!/usr/bin/env python3 """ Morrowind Pilot — Layer 1: Deterministic motor control. Three-layer architecture: Layer 3: Timmy (hermes4 14B) → strategy via current_goal.txt Layer 2: Reflex (1-3B model) → tactics via reflex.py (optional) Layer 1: Pilot (this script) → motor control, no LLM Reuses existing perception + action infrastructure from mcp_server.py and local_brain.py. Runs standalone alongside Hermes TUI. Usage: python3 ~/.timmy/morrowind/pilot.py python3 ~/.timmy/morrowind/pilot.py --interval 0.5 --cycles 0 # 0 = infinite Logs every perception-action pair to trajectories/ for DPO training. """ import argparse import json import math import os import random import re import signal import sys import time # ═══════════════════════════════════════ # CONFIG # ═══════════════════════════════════════ OPENMW_LOG = os.path.expanduser("~/Library/Preferences/openmw/openmw.log") MORROWIND_DIR = os.path.expanduser("~/.timmy/morrowind") GOAL_FILE = os.path.join(MORROWIND_DIR, "current_goal.txt") ESCALATION_FILE = os.path.join(MORROWIND_DIR, "escalation.txt") TRAJECTORY_DIR = os.path.join(MORROWIND_DIR, "trajectories") REFLEX_URL = os.environ.get("REFLEX_URL", "http://localhost:8082/v1/chat/completions") os.makedirs(TRAJECTORY_DIR, exist_ok=True) # CGEvent key codes (from local_brain.py) KEYCODES = { "w": 13, "a": 0, "s": 1, "d": 2, "space": 49, "escape": 53, "return": 36, "e": 14, "f": 3, "q": 12, "j": 38, "t": 20, "f5": 96, "f9": 101, "left": 123, "right": 124, "up": 126, "down": 125, } # ═══════════════════════════════════════ # PERCEPTION — Reused from mcp_server.py # ═══════════════════════════════════════ def parse_latest_perception(): """Parse the most recent perception block from the OpenMW log.""" try: with open(OPENMW_LOG, "r") as f: content = f.read() except FileNotFoundError: return None blocks = re.findall( r"=== TIMMY PERCEPTION ===(.*?)=== END PERCEPTION ===", content, re.DOTALL ) if not blocks: return None block = blocks[-1] state = {"npcs": [], "doors": [], "items": []} for line in block.strip().split("\n"): line = line.strip() if "]:\t" in line: line = line.split("]:\t", 1)[1] if line.startswith("Cell:"): state["cell"] = line.split(":", 1)[1].strip() elif line.startswith("Pos:"): state["position"] = line.split(":", 1)[1].strip() elif line.startswith("Yaw:"): state["yaw"] = line.split(":", 1)[1].strip() elif line.startswith("HP:"): state["health"] = line.split(":", 1)[1].strip() elif line.startswith("MP:"): state["magicka"] = line.split(":", 1)[1].strip() elif line.startswith("FT:"): state["fatigue"] = line.split(":", 1)[1].strip() elif line.startswith("Mode:"): state["mode"] = line.split(":", 1)[1].strip() elif line.startswith("Time:"): state["game_time"] = line.split(":", 1)[1].strip() elif line.startswith("NPC:"): state["npcs"].append(line[4:].strip()) elif line.startswith("Door:"): state["doors"].append(line[5:].strip()) elif line.startswith("Item:"): state["items"].append(line[5:].strip()) return state # ═══════════════════════════════════════ # ACTIONS — CGEvent keypresses # ═══════════════════════════════════════ def send_key(keycode, duration=0.0, shift=False): """Send a keypress to the game via CGEvent.""" import Quartz flags = Quartz.kCGEventFlagMaskShift if shift else 0 down = Quartz.CGEventCreateKeyboardEvent(None, keycode, True) Quartz.CGEventSetFlags(down, flags) Quartz.CGEventPost(Quartz.kCGHIDEventTap, down) if duration > 0: time.sleep(duration) up = Quartz.CGEventCreateKeyboardEvent(None, keycode, False) Quartz.CGEventSetFlags(up, 0) Quartz.CGEventPost(Quartz.kCGHIDEventTap, up) def move(direction, duration=1.0, run=False): """Move the character.""" key_map = { "forward": "w", "backward": "s", "left": "a", "right": "d", "turn_left": "left", "turn_right": "right", } key = key_map.get(direction, "w") send_key(KEYCODES[key], duration=duration, shift=run) return f"move:{direction}:{duration:.1f}s" + (":run" if run else "") def activate(): """Interact with whatever is in front of us.""" send_key(KEYCODES["space"], duration=0.15) return "activate" def jump(): send_key(KEYCODES["space"], duration=0.05) return "jump" def attack(): send_key(KEYCODES["f"], duration=0.3) return "attack" def quicksave(): send_key(KEYCODES["f5"], duration=0.1) return "quicksave" # ═══════════════════════════════════════ # GOAL — Set by Timmy (Layer 3) # ═══════════════════════════════════════ def read_goal(): """Read current strategic goal from Timmy.""" try: with open(GOAL_FILE, "r") as f: return f.read().strip() except FileNotFoundError: return "explore" def escalate(situation): """Write an escalation for Timmy to handle.""" entry = { "situation": situation, "timestamp": time.time(), "iso": time.strftime("%Y-%m-%dT%H:%M:%S"), } with open(ESCALATION_FILE, "a") as f: f.write(json.dumps(entry) + "\n") print(f" ⬆ ESCALATED: {situation[:80]}") # ═══════════════════════════════════════ # REFLEX — Optional Layer 2 (1-3B model) # ═══════════════════════════════════════ def ask_reflex(perception, goal): """Query the reflex model for a tactical decision. Falls back to behavior tree.""" try: import requests prompt = ( f"Game: {perception.get('cell', '?')}. " f"NPCs: {perception.get('npcs', [])}. " f"Doors: {perception.get('doors', [])}. " f"Items: {perception.get('items', [])}. " f"Goal: {goal}. " f"Pick ONE: explore, approach_npc, enter_door, pickup_item, fight, flee, activate, wait" ) resp = requests.post(REFLEX_URL, json={ "model": "reflex", "messages": [{"role": "user", "content": prompt}], "max_tokens": 10, "temperature": 0.3, }, timeout=2) if resp.status_code == 200: text = resp.json()["choices"][0]["message"]["content"].strip().lower() # Extract first word that matches a known action for action in ["explore", "approach_npc", "enter_door", "pickup_item", "fight", "flee", "activate", "wait"]: if action in text: return action, "reflex" except Exception: pass # Reflex not running — fall through to behavior tree return None, None # ═══════════════════════════════════════ # BEHAVIOR TREE — Deterministic fallback # ═══════════════════════════════════════ def parse_distance(entity_str): """Extract distance from entity string like 'Jobasha (dist:145.2)'.""" m = re.search(r'dist:([\d.]+)', entity_str) return float(m.group(1)) if m else 9999.0 def decide(perception, goal, history): """Deterministic behavior tree. Returns (action_name, action_func_and_args).""" npcs = perception.get("npcs", []) doors = perception.get("doors", []) items = perception.get("items", []) mode = perception.get("mode", "idle") # Stuck detection — same position for 4+ cycles positions = [h.get("position", "") for h in history[-4:]] stuck = len(positions) >= 4 and len(set(positions)) == 1 and positions[0] != "" # Priority 1: If there's an NPC nearby, approach and activate if npcs: closest = min(npcs, key=parse_distance) dist = parse_distance(closest) name = closest.split("(")[0].strip() if dist < 150: # Close enough to interact escalate(f"NPC '{name}' is right in front of us (dist:{dist:.0f}). Approaching to interact.") return "approach_npc", lambda: activate() elif dist < 500: return "approach_npc", lambda: move("forward", duration=min(dist / 200, 3.0)) # Priority 2: If there's an item nearby, approach and pick up if items: closest = min(items, key=parse_distance) dist = parse_distance(closest) if dist < 200: return "pickup_item", lambda: activate() elif dist < 500: return "pickup_item", lambda: move("forward", duration=min(dist / 200, 2.0)) # Priority 3: If there's a door nearby, go through it if doors: closest = min(doors, key=parse_distance) dist = parse_distance(closest) door_name = closest.split("(")[0].strip() if dist < 200: return "enter_door", lambda: activate() elif dist < 600: return "enter_door", lambda: move("forward", duration=min(dist / 200, 3.0)) # Priority 4: If stuck, turn randomly and try to unstick if stuck: direction = random.choice(["turn_left", "turn_right"]) duration = random.uniform(0.5, 1.5) return "unstick", lambda: (move(direction, duration), move("forward", 2.0)) # Priority 5: Explore — walk forward with occasional turns r = random.random() if r < 0.65: # Walk forward duration = random.uniform(1.5, 4.0) return "explore", lambda: move("forward", duration) elif r < 0.80: # Turn and walk direction = random.choice(["turn_left", "turn_right"]) return "explore", lambda: (move(direction, random.uniform(0.3, 1.0)), move("forward", random.uniform(1.0, 3.0))) elif r < 0.90: # Run forward (cover ground faster) return "explore", lambda: move("forward", random.uniform(2.0, 5.0), run=True) else: # Quicksave periodically return "quicksave", lambda: quicksave() # ═══════════════════════════════════════ # TRAJECTORY LOGGING # ═══════════════════════════════════════ def log_trajectory(cycle, perception, goal, action_name, source, latency_ms): """Log a semantic trajectory to JSONL.""" today = time.strftime("%Y%m%d") path = os.path.join(TRAJECTORY_DIR, f"{today}.jsonl") entry = { "cycle": cycle, "timestamp": time.time(), "iso": time.strftime("%Y-%m-%dT%H:%M:%S"), "layer": source, # "pilot" | "reflex" | "timmy" "perception": { "cell": perception.get("cell", "?"), "position": perception.get("position", "?"), "yaw": perception.get("yaw", "?"), "health": perception.get("health", "?"), "npcs": perception.get("npcs", []), "doors": perception.get("doors", []), "items": perception.get("items", []), "mode": perception.get("mode", "?"), }, "goal": goal, "action": action_name, "latency_ms": latency_ms, } with open(path, "a") as f: f.write(json.dumps(entry) + "\n") # ═══════════════════════════════════════ # MAIN LOOP # ═══════════════════════════════════════ running = True def handle_sigint(sig, frame): global running print("\n\n🛑 Pilot stopping gracefully...") running = False signal.signal(signal.SIGINT, handle_sigint) def main(): parser = argparse.ArgumentParser(description="Morrowind Pilot — Layer 1 Motor Control") parser.add_argument("--interval", type=float, default=0.5, help="Seconds between cycles (default: 0.5)") parser.add_argument("--cycles", type=int, default=0, help="Max cycles, 0=infinite (default: 0)") parser.add_argument("--reflex", action="store_true", help="Try reflex model (layer 2) before behavior tree") parser.add_argument("--quiet", action="store_true", help="Minimal output") args = parser.parse_args() print("═══════════════════════════════════════") print(" MORROWIND PILOT — Layer 1") print(" Motor control + trajectory logging") print(f" Interval: {args.interval}s") print(f" Reflex: {'enabled' if args.reflex else 'disabled (behavior tree only)'}") print(f" Goal: {read_goal()}") print(f" Logs: {TRAJECTORY_DIR}/") print("═══════════════════════════════════════") print() print(" Ctrl+C to stop cleanly") print(" Write goals to: ~/.timmy/morrowind/current_goal.txt") print(" Escalations at: ~/.timmy/morrowind/escalation.txt") print() history = [] cycle = 0 autosave_counter = 0 while running: cycle += 1 if args.cycles > 0 and cycle > args.cycles: break t0 = time.time() # Perceive perception = parse_latest_perception() if not perception: if not args.quiet: print(f" [{cycle}] No perception data. Game running?") time.sleep(args.interval * 2) continue # Read goal from Timmy goal = read_goal() # Track history for stuck detection history.append({"position": perception.get("position", ""), "cycle": cycle}) if len(history) > 10: history.pop(0) # Decide — try reflex first, fall back to behavior tree source = "pilot" if args.reflex: reflex_action, reflex_source = ask_reflex(perception, goal) if reflex_action: action_name = reflex_action source = "reflex" # Map reflex action to motor commands action_map = { "explore": lambda: move("forward", random.uniform(1.5, 3.0)), "approach_npc": lambda: move("forward", 1.5), "enter_door": lambda: (move("forward", 1.0), activate()), "pickup_item": lambda: activate(), "fight": lambda: attack(), "flee": lambda: (move("turn_left", 1.0), move("forward", 3.0, run=True)), "activate": lambda: activate(), "wait": lambda: None, } action_fn = action_map.get(reflex_action, lambda: move("forward", 1.0)) else: action_name, action_fn = decide(perception, goal, history) else: action_name, action_fn = decide(perception, goal, history) # Execute result = action_fn() latency_ms = int((time.time() - t0) * 1000) # Log if not args.quiet: cell = perception.get("cell", "?")[:25] n_npcs = len(perception.get("npcs", [])) n_doors = len(perception.get("doors", [])) n_items = len(perception.get("items", [])) print(f" [{cycle}] {cell} | {action_name} ({source}) | NPCs:{n_npcs} Doors:{n_doors} Items:{n_items} | {latency_ms}ms") log_trajectory(cycle, perception, goal, action_name, source, latency_ms) # Autosave every 100 cycles autosave_counter += 1 if autosave_counter >= 100: quicksave() autosave_counter = 0 if not args.quiet: print(f" [{cycle}] ⚡ Autosaved") # Wait elapsed = time.time() - t0 sleep_time = max(0, args.interval - elapsed) time.sleep(sleep_time) # Final stats today = time.strftime("%Y%m%d") traj_file = os.path.join(TRAJECTORY_DIR, f"{today}.jsonl") traj_count = 0 if os.path.exists(traj_file): with open(traj_file) as f: traj_count = sum(1 for _ in f) print(f"\n═══════════════════════════════════════") print(f" Pilot stopped after {cycle} cycles") print(f" Trajectories logged: {traj_count}") print(f" Log: {traj_file}") print(f"═══════════════════════════════════════") if __name__ == "__main__": main()