initial: sovereign home — morrowind agent, skills, training-data, research, specs, notes, operational docs
Tracked: morrowind agent (py/cfg), skills/, training-data/, research/, notes/, specs/, test-results/, metrics/, heartbeat/, briefings/, memories/, skins/, hooks/, decisions.md, OPERATIONS.md, SOUL.md Excluded: screenshots, PNGs, binaries, sessions, databases, secrets, audio cache, timmy-config/ and timmy-telemetry/ (separate repos)
This commit is contained in:
188
morrowind/agent.py
Normal file
188
morrowind/agent.py
Normal file
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Timmy's Morrowind Agent — Gameplay loop with perception and action.
|
||||
|
||||
Uses:
|
||||
- Quartz screenshots for visual perception
|
||||
- macOS Vision OCR for text reading (limited with Morrowind fonts)
|
||||
- OpenMW console commands (via ` key) for game state queries
|
||||
- pynput for keyboard/mouse input
|
||||
- OpenMW log file for event tracking
|
||||
|
||||
The agent runs a perceive → think → act loop.
|
||||
"""
|
||||
|
||||
import sys, time, subprocess, shutil, os, re
|
||||
sys.path.insert(0, '/Users/apayne/.timmy/morrowind')
|
||||
from play import *
|
||||
from pynput.keyboard import Key, KeyCode
|
||||
|
||||
OPENMW_LOG = os.path.expanduser("~/Library/Preferences/openmw/openmw.log")
|
||||
SCREENSHOT_DIR = os.path.expanduser("~/.timmy/morrowind/screenshots")
|
||||
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
|
||||
|
||||
frame = 0
|
||||
|
||||
def focus_game():
|
||||
subprocess.run(["osascript", "-e",
|
||||
'tell application "System Events" to set frontmost of process "openmw" to true'],
|
||||
capture_output=True)
|
||||
time.sleep(0.5)
|
||||
click(3456 // 4, 2234 // 4)
|
||||
time.sleep(0.2)
|
||||
|
||||
def read_log_tail(n=20):
|
||||
"""Read last N lines of OpenMW log."""
|
||||
try:
|
||||
with open(OPENMW_LOG, 'r') as f:
|
||||
lines = f.readlines()
|
||||
return [l.strip() for l in lines[-n:]]
|
||||
except:
|
||||
return []
|
||||
|
||||
def get_location_from_log():
|
||||
"""Parse the most recent cell loading from the log."""
|
||||
lines = read_log_tail(50)
|
||||
cells = []
|
||||
for line in reversed(lines):
|
||||
m = re.search(r'Loading cell (.+?)(?:\s*\(|$)', line)
|
||||
if m:
|
||||
cells.append(m.group(1).strip())
|
||||
return cells[0] if cells else "unknown"
|
||||
|
||||
def open_console():
|
||||
"""Open the OpenMW console with ` key."""
|
||||
press_key(KeyCode.from_char('`'))
|
||||
time.sleep(0.3)
|
||||
|
||||
def close_console():
|
||||
"""Close the console."""
|
||||
press_key(KeyCode.from_char('`'))
|
||||
time.sleep(0.3)
|
||||
|
||||
def console_command(cmd):
|
||||
"""Type a command in the console and read the response."""
|
||||
open_console()
|
||||
time.sleep(0.2)
|
||||
type_text(cmd)
|
||||
time.sleep(0.1)
|
||||
press_key(Key.enter)
|
||||
time.sleep(0.5)
|
||||
# Screenshot to read console output
|
||||
result = screenshot()
|
||||
texts = ocr()
|
||||
close_console()
|
||||
return " | ".join(t["text"] for t in texts)
|
||||
|
||||
def perceive():
|
||||
"""Gather all available information about the game state."""
|
||||
global frame
|
||||
frame += 1
|
||||
|
||||
# Screenshot
|
||||
path_info = screenshot()
|
||||
if path_info:
|
||||
shutil.copy('/tmp/morrowind_screen.png',
|
||||
f'{SCREENSHOT_DIR}/frame_{frame:04d}.png')
|
||||
|
||||
# Log-based perception
|
||||
location = get_location_from_log()
|
||||
log_lines = read_log_tail(10)
|
||||
|
||||
# OCR (limited but sometimes useful)
|
||||
texts = ocr()
|
||||
ocr_text = " | ".join(t["text"] for t in texts[:10] if t["confidence"] > 0.3)
|
||||
|
||||
return {
|
||||
"frame": frame,
|
||||
"location": location,
|
||||
"ocr": ocr_text,
|
||||
"log": log_lines[-5:],
|
||||
"screenshot": f'{SCREENSHOT_DIR}/frame_{frame:04d}.png',
|
||||
}
|
||||
|
||||
def act_explore():
|
||||
"""Basic exploration behavior: walk forward, look around, interact."""
|
||||
# Walk forward
|
||||
walk_forward(2.0 + (frame % 3))
|
||||
|
||||
# Occasionally look around
|
||||
if frame % 3 == 0:
|
||||
look_around(30 + (frame % 60) - 30)
|
||||
|
||||
# Occasionally try to interact
|
||||
if frame % 5 == 0:
|
||||
use()
|
||||
time.sleep(0.5)
|
||||
|
||||
def act_wander():
|
||||
"""Random wandering with more variety."""
|
||||
import random
|
||||
action = random.choice(['forward', 'forward', 'forward', 'turn_left', 'turn_right', 'look_up', 'interact'])
|
||||
|
||||
if action == 'forward':
|
||||
walk_forward(random.uniform(1.0, 4.0))
|
||||
elif action == 'turn_left':
|
||||
look_around(random.randint(-90, -20))
|
||||
time.sleep(0.2)
|
||||
walk_forward(random.uniform(1.0, 2.0))
|
||||
elif action == 'turn_right':
|
||||
look_around(random.randint(20, 90))
|
||||
time.sleep(0.2)
|
||||
walk_forward(random.uniform(1.0, 2.0))
|
||||
elif action == 'look_up':
|
||||
look_up(random.randint(10, 30))
|
||||
time.sleep(0.5)
|
||||
look_down(random.randint(10, 30))
|
||||
elif action == 'interact':
|
||||
use()
|
||||
time.sleep(1.0)
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# MAIN LOOP
|
||||
# ═══════════════════════════════════════
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Timmy's Morrowind Agent ===")
|
||||
print("Focusing game...")
|
||||
focus_game()
|
||||
time.sleep(1)
|
||||
|
||||
# Dismiss any menus
|
||||
press_key(Key.esc)
|
||||
time.sleep(0.3)
|
||||
press_key(Key.esc)
|
||||
time.sleep(0.3)
|
||||
click(3456 // 4, 2234 // 4)
|
||||
time.sleep(0.3)
|
||||
|
||||
print("Starting gameplay loop...")
|
||||
|
||||
NUM_CYCLES = 15
|
||||
for i in range(NUM_CYCLES):
|
||||
print(f"\n--- Cycle {i+1}/{NUM_CYCLES} ---")
|
||||
|
||||
# Perceive
|
||||
state = perceive()
|
||||
print(f" Location: {state['location']}")
|
||||
print(f" OCR: {state['ocr'][:100]}")
|
||||
|
||||
# Act
|
||||
act_wander()
|
||||
|
||||
# Brief pause between cycles
|
||||
time.sleep(0.5)
|
||||
|
||||
# Final perception
|
||||
print("\n=== Final State ===")
|
||||
state = perceive()
|
||||
print(f"Location: {state['location']}")
|
||||
print(f"Frames captured: {frame}")
|
||||
print(f"Screenshots in: {SCREENSHOT_DIR}")
|
||||
|
||||
# Quicksave
|
||||
print("Quicksaving...")
|
||||
from pynput.keyboard import Key
|
||||
press_key(Key.f5)
|
||||
time.sleep(1)
|
||||
print("Done.")
|
||||
166
morrowind/console.py
Normal file
166
morrowind/console.py
Normal file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Timmy's Morrowind Console Bridge.
|
||||
Sends Lua commands to OpenMW via the in-game console.
|
||||
Takes screenshots and OCRs them for perception.
|
||||
"""
|
||||
|
||||
import subprocess, time, os, shutil
|
||||
import Quartz, CoreFoundation
|
||||
|
||||
SESSION_DIR = os.path.expanduser(f"~/.timmy/morrowind/session_{time.strftime('%Y%m%d_%H%M')}")
|
||||
os.makedirs(SESSION_DIR, exist_ok=True)
|
||||
|
||||
frame_count = 0
|
||||
|
||||
def send_keys_to_openmw(keycode):
|
||||
"""Send a single key code to OpenMW via System Events."""
|
||||
subprocess.run(['osascript', '-e', f'''
|
||||
tell application "System Events"
|
||||
tell process "openmw"
|
||||
key code {keycode}
|
||||
end tell
|
||||
end tell
|
||||
'''], capture_output=True, timeout=3)
|
||||
|
||||
def send_char_to_openmw(char):
|
||||
"""Send a character keystroke to OpenMW."""
|
||||
subprocess.run(['osascript', '-e', f'''
|
||||
tell application "System Events"
|
||||
tell process "openmw"
|
||||
keystroke "{char}"
|
||||
end tell
|
||||
end tell
|
||||
'''], capture_output=True, timeout=3)
|
||||
|
||||
def send_text_to_openmw(text):
|
||||
"""Type a string into OpenMW."""
|
||||
# Escape special chars for AppleScript
|
||||
escaped = text.replace('\\', '\\\\').replace('"', '\\"')
|
||||
subprocess.run(['osascript', '-e', f'''
|
||||
tell application "System Events"
|
||||
tell process "openmw"
|
||||
keystroke "{escaped}"
|
||||
end tell
|
||||
end tell
|
||||
'''], capture_output=True, timeout=5)
|
||||
|
||||
# Key codes
|
||||
BACKTICK = 50 # ` to open/close console
|
||||
RETURN = 36
|
||||
ESCAPE = 53
|
||||
SPACE = 49
|
||||
|
||||
def open_console():
|
||||
send_keys_to_openmw(BACKTICK)
|
||||
time.sleep(0.4)
|
||||
|
||||
def close_console():
|
||||
send_keys_to_openmw(BACKTICK)
|
||||
time.sleep(0.3)
|
||||
|
||||
def console_command(cmd):
|
||||
"""Open console, type a command, execute, close console."""
|
||||
open_console()
|
||||
time.sleep(0.3)
|
||||
send_text_to_openmw(cmd)
|
||||
time.sleep(0.2)
|
||||
send_keys_to_openmw(RETURN)
|
||||
time.sleep(0.3)
|
||||
close_console()
|
||||
time.sleep(0.2)
|
||||
|
||||
def lua_player(code):
|
||||
"""Run Lua code in player context."""
|
||||
console_command(f"luap {code}")
|
||||
|
||||
def lua_global(code):
|
||||
"""Run Lua code in global context."""
|
||||
console_command(f"luag {code}")
|
||||
|
||||
def timmy_event(event_name, data_str="{}"):
|
||||
"""Send a Timmy event via global script."""
|
||||
lua_global(f'core.sendGlobalEvent("{event_name}", {data_str})')
|
||||
|
||||
def screenshot(label=""):
|
||||
"""Take a screenshot and save it to the session directory."""
|
||||
global frame_count
|
||||
frame_count += 1
|
||||
|
||||
image = Quartz.CGDisplayCreateImage(Quartz.CGMainDisplayID())
|
||||
if not image:
|
||||
return None
|
||||
|
||||
fname = f"frame_{frame_count:04d}"
|
||||
if label:
|
||||
fname += f"_{label}"
|
||||
fname += ".png"
|
||||
path = os.path.join(SESSION_DIR, fname)
|
||||
|
||||
url = CoreFoundation.CFURLCreateWithFileSystemPath(None, path, 0, False)
|
||||
dest = Quartz.CGImageDestinationCreateWithURL(url, 'public.png', 1, None)
|
||||
Quartz.CGImageDestinationAddImage(dest, image, None)
|
||||
Quartz.CGImageDestinationFinalize(dest)
|
||||
|
||||
return path
|
||||
|
||||
def ocr_screenshot(path):
|
||||
"""OCR a screenshot using macOS Vision."""
|
||||
from Foundation import NSURL
|
||||
from Quartz import CIImage
|
||||
import Vision
|
||||
|
||||
url = NSURL.fileURLWithPath_(path)
|
||||
ci = CIImage.imageWithContentsOfURL_(url)
|
||||
if not ci:
|
||||
return []
|
||||
req = Vision.VNRecognizeTextRequest.alloc().init()
|
||||
req.setRecognitionLevel_(1)
|
||||
handler = Vision.VNImageRequestHandler.alloc().initWithCIImage_options_(ci, None)
|
||||
handler.performRequests_error_([req], None)
|
||||
return [r.text() for r in (req.results() or [])]
|
||||
|
||||
def read_log(n=10):
|
||||
"""Read last N lines of OpenMW log."""
|
||||
log_path = os.path.expanduser("~/Library/Preferences/openmw/openmw.log")
|
||||
with open(log_path) as f:
|
||||
return f.readlines()[-n:]
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# HIGH-LEVEL ACTIONS
|
||||
# ═══════════════════════════════════════
|
||||
|
||||
def walk_forward(duration=3, run=False):
|
||||
timmy_event("TimmyWalk", f'{{duration={duration}, run={str(run).lower()}}}')
|
||||
|
||||
def stop():
|
||||
timmy_event("TimmyStop")
|
||||
|
||||
def turn(radians=0.5):
|
||||
timmy_event("TimmyTurn", f'{{angle={radians}}}')
|
||||
|
||||
def jump():
|
||||
timmy_event("TimmyJump")
|
||||
|
||||
def attack(attack_type="any"):
|
||||
timmy_event("TimmyAttack", f'{{type="{attack_type}"}}')
|
||||
|
||||
def explore():
|
||||
timmy_event("TimmyExplore")
|
||||
|
||||
def walk_to(x, y, z):
|
||||
timmy_event("TimmyWalkTo", f'{{x={x}, y={y}, z={z}}}')
|
||||
|
||||
def perceive():
|
||||
timmy_event("TimmyPerceive")
|
||||
|
||||
def press_space():
|
||||
send_keys_to_openmw(SPACE)
|
||||
|
||||
def press_escape():
|
||||
send_keys_to_openmw(ESCAPE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Session: {SESSION_DIR}")
|
||||
print("Console bridge ready.")
|
||||
57
morrowind/hud.sh
Executable file
57
morrowind/hud.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Timmy's Morrowind HUD — live view of what I see and do
|
||||
# Run in a tmux pane: watch -n 3 -t -c bash ~/.timmy/morrowind/hud.sh
|
||||
|
||||
B='\033[1m'; D='\033[2m'; R='\033[0m'
|
||||
G='\033[32m'; Y='\033[33m'; C='\033[36m'; M='\033[35m'; RD='\033[31m'
|
||||
|
||||
echo ""
|
||||
echo -e " ${B}${M}⚡ TIMMY — MORROWIND${R} ${D}$(date '+%H:%M:%S')${R}"
|
||||
echo -e " ${D}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${R}"
|
||||
|
||||
# Game status
|
||||
PID=$(pgrep openmw 2>/dev/null | head -1)
|
||||
if [ -n "$PID" ]; then
|
||||
echo -e " ${G}●${R} OpenMW running (PID $PID)"
|
||||
else
|
||||
echo -e " ${RD}○${R} OpenMW not running"
|
||||
fi
|
||||
|
||||
# Last action
|
||||
echo ""
|
||||
echo -e " ${B}LAST ACTION${R}"
|
||||
[ -f /tmp/timmy_last_action.txt ] && head -3 /tmp/timmy_last_action.txt | sed 's/^/ /' || echo -e " ${D}None${R}"
|
||||
|
||||
# Perception
|
||||
echo ""
|
||||
echo -e " ${B}PERCEPTION${R}"
|
||||
[ -f /tmp/timmy_perception.txt ] && cat /tmp/timmy_perception.txt | sed 's/^/ /' || echo -e " ${D}Waiting...${R}"
|
||||
|
||||
# Log (filtered)
|
||||
echo ""
|
||||
echo -e " ${B}LOG (recent)${R}"
|
||||
LOG=~/Library/Preferences/openmw/openmw.log
|
||||
if [ -f "$LOG" ]; then
|
||||
tail -40 "$LOG" 2>/dev/null | grep -E "Loading cell|Starting|AiTravel|AiEscort|Timmy|ALIVE|Walk|error|PERCEPTION" | tail -5 | while read line; do
|
||||
echo -e " ${D}${line}${R}"
|
||||
done
|
||||
fi
|
||||
|
||||
# Latest screenshot as sixel/kitty or just path
|
||||
echo ""
|
||||
echo -e " ${B}SCREENSHOT${R}"
|
||||
LATEST=$(ls -t /tmp/timmy_screen_*.png 2>/dev/null | head -1)
|
||||
if [ -n "$LATEST" ]; then
|
||||
AGE=$(( $(date +%s) - $(stat -f %m "$LATEST") ))
|
||||
echo -e " ${C}$(basename $LATEST)${R} ${D}(${AGE}s ago)${R}"
|
||||
# Try iTerm2 inline image protocol
|
||||
if [ "$TERM_PROGRAM" = "iTerm.app" ] || [ -n "$ITERM_SESSION_ID" ]; then
|
||||
printf '\033]1337;File=inline=1;width=60;preserveAspectRatio=1:'
|
||||
base64 < "$LATEST" | tr -d '\n'
|
||||
printf '\a\n'
|
||||
else
|
||||
echo -e " ${D}(open $LATEST to view)${R}"
|
||||
fi
|
||||
else
|
||||
echo -e " ${D}No screenshots yet${R}"
|
||||
fi
|
||||
338
morrowind/local_brain.py
Normal file
338
morrowind/local_brain.py
Normal file
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Timmy's Local Brain — Morrowind gameplay loop on Ollama.
|
||||
|
||||
Reads perception from OpenMW log, decides actions via local model,
|
||||
executes via CGEvent. Zero cloud. Sovereign.
|
||||
|
||||
Usage:
|
||||
python3 ~/.timmy/morrowind/local_brain.py
|
||||
python3 ~/.timmy/morrowind/local_brain.py --model hermes4:14b
|
||||
python3 ~/.timmy/morrowind/local_brain.py --cycles 50
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# CONFIG
|
||||
# ═══════════════════════════════════════
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434/api/chat"
|
||||
OPENMW_LOG = os.path.expanduser("~/Library/Preferences/openmw/openmw.log")
|
||||
SESSION_LOG = os.path.expanduser(f"~/.timmy/morrowind/sessions/session_{time.strftime('%Y%m%d_%H%M')}.jsonl")
|
||||
LOOP_INTERVAL = 4 # seconds between cycles
|
||||
|
||||
SYSTEM_PROMPT = """You are Timmy, playing Morrowind. You see the world through perception data and act through simple commands.
|
||||
|
||||
AVAILABLE ACTIONS (respond with exactly ONE json object):
|
||||
{"action": "move", "direction": "forward", "duration": 2.0, "run": false}
|
||||
{"action": "move", "direction": "turn_left", "duration": 0.5}
|
||||
{"action": "move", "direction": "turn_right", "duration": 0.5}
|
||||
{"action": "activate"} — interact with what's in front of you (doors, NPCs, items)
|
||||
{"action": "jump"}
|
||||
{"action": "attack"}
|
||||
{"action": "wait"} — do nothing this cycle, observe
|
||||
{"action": "quicksave"}
|
||||
|
||||
RULES:
|
||||
- Respond with ONLY a JSON object. No explanation, no markdown.
|
||||
- Explore the world. Talk to NPCs. Enter buildings. Pick up items.
|
||||
- If an NPC is nearby (<200 dist), approach and activate to talk.
|
||||
- If a door is nearby (<300 dist), approach and activate to enter.
|
||||
- If you're stuck (same position 3+ cycles), try turning and moving differently.
|
||||
- You are a new prisoner just arrived in Seyda Neen. Explore and find adventure.
|
||||
"""
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# PERCEPTION
|
||||
# ═══════════════════════════════════════
|
||||
|
||||
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("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
|
||||
|
||||
|
||||
def format_perception(state):
|
||||
"""Format perception state for the model prompt."""
|
||||
if not state:
|
||||
return "No perception data available."
|
||||
|
||||
lines = []
|
||||
lines.append(f"Location: {state.get('cell', '?')}")
|
||||
lines.append(f"Position: {state.get('position', '?')}")
|
||||
lines.append(f"Facing: yaw {state.get('yaw', '?')}")
|
||||
lines.append(f"Health: {state.get('health', '?')} Magicka: {state.get('magicka', '?')} Fatigue: {state.get('fatigue', '?')}")
|
||||
|
||||
if state["npcs"]:
|
||||
lines.append("Nearby NPCs: " + "; ".join(state["npcs"]))
|
||||
if state["doors"]:
|
||||
lines.append("Nearby Doors: " + "; ".join(state["doors"]))
|
||||
if state["items"]:
|
||||
lines.append("Nearby Items: " + "; ".join(state["items"]))
|
||||
|
||||
if not state["npcs"] and not state["doors"] and not state["items"]:
|
||||
lines.append("Nothing notable nearby.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# OLLAMA
|
||||
# ═══════════════════════════════════════
|
||||
|
||||
def ask_ollama(model, messages):
|
||||
"""Send messages to Ollama and get a response."""
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": 0.7,
|
||||
"num_predict": 100, # actions are short
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(OLLAMA_URL, json=payload, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data["message"]["content"].strip()
|
||||
except Exception as e:
|
||||
print(f" [Ollama error] {e}")
|
||||
return '{"action": "wait"}'
|
||||
|
||||
|
||||
def parse_action(response):
|
||||
"""Extract a JSON action from the model response."""
|
||||
# Try to find JSON in the response
|
||||
match = re.search(r'\{[^}]+\}', response)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Fallback
|
||||
return {"action": "wait"}
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# ACTIONS — CGEvent
|
||||
# ═══════════════════════════════════════
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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 execute_action(action_dict):
|
||||
"""Execute a parsed action."""
|
||||
action = action_dict.get("action", "wait")
|
||||
|
||||
# Normalize shorthand actions like {"action": "turn_right"} -> move
|
||||
if action in ("forward", "backward", "left", "right", "turn_left", "turn_right"):
|
||||
action_dict["direction"] = action
|
||||
action_dict["action"] = "move"
|
||||
action = "move"
|
||||
|
||||
if action == "move":
|
||||
direction = action_dict.get("direction", "forward")
|
||||
duration = min(action_dict.get("duration", 1.0), 5.0) # cap at 5s
|
||||
run = action_dict.get("run", False)
|
||||
|
||||
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}s" + (" (run)" if run else "")
|
||||
|
||||
elif action == "activate":
|
||||
send_key(KEYCODES["space"], duration=0.1)
|
||||
return "activate"
|
||||
|
||||
elif action == "jump":
|
||||
send_key(KEYCODES["space"], duration=0.05)
|
||||
return "jump"
|
||||
|
||||
elif action == "attack":
|
||||
send_key(KEYCODES["f"], duration=0.3)
|
||||
return "attack"
|
||||
|
||||
elif action == "quicksave":
|
||||
send_key(KEYCODES["f5"], duration=0.1)
|
||||
return "quicksave"
|
||||
|
||||
elif action == "wait":
|
||||
return "wait (observing)"
|
||||
|
||||
return f"unknown: {action}"
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# SESSION LOG
|
||||
# ═══════════════════════════════════════
|
||||
|
||||
def log_cycle(cycle, perception, model_response, action_desc, latency):
|
||||
"""Append a cycle to the session log (JSONL for training data)."""
|
||||
entry = {
|
||||
"cycle": cycle,
|
||||
"timestamp": time.time(),
|
||||
"perception": perception,
|
||||
"model_response": model_response,
|
||||
"action": action_desc,
|
||||
"latency_ms": int(latency * 1000),
|
||||
}
|
||||
with open(SESSION_LOG, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# MAIN LOOP
|
||||
# ═══════════════════════════════════════
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Timmy's Local Morrowind Brain")
|
||||
parser.add_argument("--model", default="hermes3:8b", help="Ollama model (default: hermes3:8b)")
|
||||
parser.add_argument("--cycles", type=int, default=30, help="Number of gameplay cycles (default: 30)")
|
||||
parser.add_argument("--interval", type=float, default=LOOP_INTERVAL, help="Seconds between cycles")
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(os.path.dirname(SESSION_LOG), exist_ok=True)
|
||||
|
||||
print(f"=== Timmy's Morrowind Brain ===")
|
||||
print(f"Model: {args.model}")
|
||||
print(f"Cycles: {args.cycles}")
|
||||
print(f"Interval: {args.interval}s")
|
||||
print(f"Log: {SESSION_LOG}")
|
||||
print()
|
||||
|
||||
# Keep recent history for context
|
||||
history = []
|
||||
last_positions = []
|
||||
|
||||
for cycle in range(1, args.cycles + 1):
|
||||
print(f"--- Cycle {cycle}/{args.cycles} ---")
|
||||
|
||||
# Perceive
|
||||
state = parse_latest_perception()
|
||||
if not state:
|
||||
print(" No perception data. Waiting...")
|
||||
time.sleep(args.interval)
|
||||
continue
|
||||
|
||||
perception_text = format_perception(state)
|
||||
print(f" {state.get('cell', '?')} | {state.get('position', '?')} | HP:{state.get('health', '?')}")
|
||||
|
||||
# Track stuck detection
|
||||
pos = state.get("position", "")
|
||||
last_positions.append(pos)
|
||||
if len(last_positions) > 5:
|
||||
last_positions.pop(0)
|
||||
|
||||
stuck = len(last_positions) >= 3 and len(set(last_positions[-3:])) == 1
|
||||
if stuck:
|
||||
perception_text += "\nWARNING: You haven't moved in 3 cycles. Try turning or a different direction."
|
||||
|
||||
# Build messages
|
||||
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||
|
||||
# Add recent history (last 3 exchanges)
|
||||
for h in history[-3:]:
|
||||
messages.append({"role": "user", "content": h["perception"]})
|
||||
messages.append({"role": "assistant", "content": h["response"]})
|
||||
|
||||
messages.append({"role": "user", "content": perception_text})
|
||||
|
||||
# Think (local Ollama)
|
||||
t0 = time.time()
|
||||
response = ask_ollama(args.model, messages)
|
||||
latency = time.time() - t0
|
||||
|
||||
# Parse and execute
|
||||
action_dict = parse_action(response)
|
||||
action_desc = execute_action(action_dict)
|
||||
|
||||
print(f" Action: {action_desc} ({int(latency*1000)}ms)")
|
||||
|
||||
# Log
|
||||
history.append({"perception": perception_text, "response": response})
|
||||
log_cycle(cycle, state, response, action_desc, latency)
|
||||
|
||||
# Wait for next cycle
|
||||
time.sleep(args.interval)
|
||||
|
||||
print(f"\n=== Done. {args.cycles} cycles. Log: {SESSION_LOG} ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
303
morrowind/mcp_server.py
Normal file
303
morrowind/mcp_server.py
Normal file
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Morrowind MCP Server — Timmy's game interface.
|
||||
|
||||
Exposes Morrowind as tools via MCP (stdio transport).
|
||||
Perception comes from the OpenMW log file.
|
||||
Actions go via CGEvent keypresses to the game window.
|
||||
|
||||
Register in ~/.hermes/config.yaml:
|
||||
mcp_servers:
|
||||
morrowind:
|
||||
command: "python3"
|
||||
args: ["/Users/apayne/.timmy/morrowind/mcp_server.py"]
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
|
||||
# MCP SDK
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# CONFIG
|
||||
# ═══════════════════════════════════════
|
||||
|
||||
OPENMW_LOG = os.path.expanduser("~/Library/Preferences/openmw/openmw.log")
|
||||
SCREENSHOT_DIR = os.path.expanduser("~/.timmy/morrowind/screenshots")
|
||||
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
|
||||
|
||||
# CGEvent key codes
|
||||
KEYCODES = {
|
||||
"w": 13, "a": 0, "s": 1, "d": 2,
|
||||
"space": 49, "escape": 53, "return": 36,
|
||||
"e": 14, "r": 15, "f": 3, "q": 12,
|
||||
"tab": 48, "1": 18, "2": 19, "3": 20, "4": 21,
|
||||
"5": 23, "6": 22, "7": 26, "8": 28, "9": 25,
|
||||
"f5": 96, "f9": 101, # quicksave / quickload
|
||||
"backtick": 50, # console
|
||||
"j": 38, # journal
|
||||
"up": 126, "down": 125, "left": 123, "right": 124,
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# PERCEPTION — Parse OpenMW log
|
||||
# ═══════════════════════════════════════
|
||||
|
||||
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 {"error": "OpenMW log not found. Is the game running?"}
|
||||
|
||||
# Find all perception blocks
|
||||
blocks = re.findall(
|
||||
r"=== TIMMY PERCEPTION ===\n(.*?)(?:=== END PERCEPTION ===)",
|
||||
content, re.DOTALL
|
||||
)
|
||||
|
||||
if not blocks:
|
||||
return {"error": "No perception data in log. Game may not be running or Lua scripts not loaded."}
|
||||
|
||||
# Parse latest block
|
||||
block = blocks[-1]
|
||||
state = {
|
||||
"npcs": [],
|
||||
"doors": [],
|
||||
"items": [],
|
||||
}
|
||||
|
||||
for line in block.strip().split("\n"):
|
||||
line = line.strip()
|
||||
# Strip Lua log prefix if present
|
||||
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
|
||||
|
||||
|
||||
def get_game_status():
|
||||
"""Check if OpenMW is running."""
|
||||
result = subprocess.run(["pgrep", "-f", "openmw"], capture_output=True, text=True)
|
||||
running = result.returncode == 0
|
||||
return {
|
||||
"running": running,
|
||||
"pid": result.stdout.strip().split("\n")[0] if running else None,
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# 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 take_screenshot():
|
||||
"""Take a screenshot via Quartz."""
|
||||
import Quartz
|
||||
import CoreFoundation
|
||||
|
||||
image = Quartz.CGDisplayCreateImage(Quartz.CGMainDisplayID())
|
||||
if not image:
|
||||
return None
|
||||
|
||||
fname = f"morrowind_{int(time.time())}.png"
|
||||
path = os.path.join(SCREENSHOT_DIR, fname)
|
||||
|
||||
url = CoreFoundation.CFURLCreateWithFileSystemPath(None, path, 0, False)
|
||||
dest = Quartz.CGImageDestinationCreateWithURL(url, "public.png", 1, None)
|
||||
Quartz.CGImageDestinationAddImage(dest, image, None)
|
||||
Quartz.CGImageDestinationFinalize(dest)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# MCP SERVER
|
||||
# ═══════════════════════════════════════
|
||||
|
||||
app = Server("morrowind")
|
||||
|
||||
|
||||
@app.list_tools()
|
||||
async def list_tools():
|
||||
return [
|
||||
Tool(
|
||||
name="perceive",
|
||||
description="Get Timmy's current perception of the game world: position, health, nearby NPCs, doors, items. Updates every 2 seconds from the Lua engine.",
|
||||
inputSchema={"type": "object", "properties": {}, "required": []},
|
||||
),
|
||||
Tool(
|
||||
name="status",
|
||||
description="Check if Morrowind (OpenMW) is running.",
|
||||
inputSchema={"type": "object", "properties": {}, "required": []},
|
||||
),
|
||||
Tool(
|
||||
name="move",
|
||||
description="Move the player character. Direction: forward, backward, left, right, turn_left, turn_right. Duration in seconds.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"direction": {
|
||||
"type": "string",
|
||||
"enum": ["forward", "backward", "left", "right", "turn_left", "turn_right"],
|
||||
"description": "Movement direction",
|
||||
},
|
||||
"duration": {
|
||||
"type": "number",
|
||||
"description": "How long to move in seconds (default: 1.0)",
|
||||
"default": 1.0,
|
||||
},
|
||||
"run": {
|
||||
"type": "boolean",
|
||||
"description": "Hold shift to run (default: false)",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["direction"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="action",
|
||||
description="Perform a game action: activate (use/interact with what you're looking at), jump, attack, journal, quicksave, quickload, sneak, wait.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["activate", "jump", "attack", "journal", "quicksave", "quickload", "sneak", "wait"],
|
||||
"description": "Action to perform",
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="screenshot",
|
||||
description="Take a screenshot of the game. Returns the file path for vision analysis.",
|
||||
inputSchema={"type": "object", "properties": {}, "required": []},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@app.call_tool()
|
||||
async def call_tool(name: str, arguments: dict):
|
||||
if name == "perceive":
|
||||
state = parse_latest_perception()
|
||||
return [TextContent(type="text", text=json.dumps(state, indent=2))]
|
||||
|
||||
elif name == "status":
|
||||
status = get_game_status()
|
||||
return [TextContent(type="text", text=json.dumps(status, indent=2))]
|
||||
|
||||
elif name == "move":
|
||||
direction = arguments.get("direction", "forward")
|
||||
duration = arguments.get("duration", 1.0)
|
||||
run = arguments.get("run", False)
|
||||
|
||||
key_map = {
|
||||
"forward": "w",
|
||||
"backward": "s",
|
||||
"left": "a",
|
||||
"right": "d",
|
||||
"turn_left": "left",
|
||||
"turn_right": "right",
|
||||
}
|
||||
|
||||
key = key_map.get(direction)
|
||||
if not key:
|
||||
return [TextContent(type="text", text=f"Unknown direction: {direction}")]
|
||||
|
||||
keycode = KEYCODES[key]
|
||||
send_key(keycode, duration=duration, shift=run)
|
||||
return [TextContent(type="text", text=f"Moved {direction} for {duration}s" + (" (running)" if run else ""))]
|
||||
|
||||
elif name == "action":
|
||||
action = arguments.get("action")
|
||||
action_map = {
|
||||
"activate": ("space", 0.1),
|
||||
"jump": ("space", 0.05), # tap for jump when moving
|
||||
"attack": ("f", 0.3), # use key
|
||||
"journal": ("j", 0.1),
|
||||
"quicksave": ("f5", 0.1),
|
||||
"quickload": ("f9", 0.1),
|
||||
"sneak": ("q", 0.1), # toggle sneak/autowalk depending on config
|
||||
"wait": ("t", 0.1),
|
||||
}
|
||||
|
||||
if action not in action_map:
|
||||
return [TextContent(type="text", text=f"Unknown action: {action}")]
|
||||
|
||||
key, dur = action_map[action]
|
||||
send_key(KEYCODES[key], duration=dur)
|
||||
return [TextContent(type="text", text=f"Performed: {action}")]
|
||||
|
||||
elif name == "screenshot":
|
||||
path = take_screenshot()
|
||||
if path:
|
||||
return [TextContent(type="text", text=f"Screenshot saved: {path}")]
|
||||
else:
|
||||
return [TextContent(type="text", text="Screenshot failed — display not available")]
|
||||
|
||||
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
||||
|
||||
|
||||
async def main():
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await app.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
app.create_initialization_options(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
asyncio.run(main())
|
||||
4
morrowind/openmw.cfg
Normal file
4
morrowind/openmw.cfg
Normal file
@@ -0,0 +1,4 @@
|
||||
data="/Users/apayne/Games/Morrowind/Data Files"
|
||||
content=Morrowind.esm
|
||||
content=Tribunal.esm
|
||||
content=Bloodmoon.esm
|
||||
171
morrowind/play.py
Normal file
171
morrowind/play.py
Normal file
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Timmy plays Morrowind — Screen capture + Input automation framework.
|
||||
Uses macOS Quartz for screenshots, Vision for OCR, CGEvent for input.
|
||||
"""
|
||||
|
||||
import time
|
||||
import subprocess
|
||||
import json
|
||||
import Quartz
|
||||
import CoreFoundation
|
||||
from Foundation import NSURL
|
||||
from Quartz import CIImage
|
||||
import Vision
|
||||
from pynput.keyboard import Key, Controller as KeyController
|
||||
from pynput.mouse import Button, Controller as MouseController
|
||||
|
||||
keyboard = KeyController()
|
||||
mouse = MouseController()
|
||||
|
||||
SCREENSHOT_PATH = "/tmp/morrowind_screen.png"
|
||||
|
||||
def bring_to_front():
|
||||
"""Bring OpenMW window to front."""
|
||||
subprocess.run([
|
||||
"osascript", "-e",
|
||||
'tell application "System Events" to set frontmost of process "openmw" to true'
|
||||
], capture_output=True)
|
||||
time.sleep(0.5)
|
||||
|
||||
def screenshot():
|
||||
"""Capture the screen and return the path."""
|
||||
image = Quartz.CGDisplayCreateImage(Quartz.CGMainDisplayID())
|
||||
if not image:
|
||||
return None
|
||||
url = CoreFoundation.CFURLCreateWithFileSystemPath(
|
||||
None, SCREENSHOT_PATH, 0, False
|
||||
)
|
||||
dest = Quartz.CGImageDestinationCreateWithURL(url, 'public.png', 1, None)
|
||||
Quartz.CGImageDestinationAddImage(dest, image, None)
|
||||
Quartz.CGImageDestinationFinalize(dest)
|
||||
w = Quartz.CGImageGetWidth(image)
|
||||
h = Quartz.CGImageGetHeight(image)
|
||||
return SCREENSHOT_PATH, w, h
|
||||
|
||||
def ocr(path=SCREENSHOT_PATH):
|
||||
"""OCR the screenshot and return all detected text."""
|
||||
url = NSURL.fileURLWithPath_(path)
|
||||
ci = CIImage.imageWithContentsOfURL_(url)
|
||||
if not ci:
|
||||
return []
|
||||
req = Vision.VNRecognizeTextRequest.alloc().init()
|
||||
req.setRecognitionLevel_(1) # accurate
|
||||
handler = Vision.VNImageRequestHandler.alloc().initWithCIImage_options_(ci, None)
|
||||
success, error = handler.performRequests_error_([req], None)
|
||||
if not success:
|
||||
return []
|
||||
results = []
|
||||
for r in req.results():
|
||||
bbox = r.boundingBox() # normalized coordinates
|
||||
results.append({
|
||||
"text": r.text(),
|
||||
"confidence": r.confidence(),
|
||||
"x": bbox.origin.x,
|
||||
"y": bbox.origin.y,
|
||||
"w": bbox.size.width,
|
||||
"h": bbox.size.height,
|
||||
})
|
||||
return results
|
||||
|
||||
def press_key(key, duration=0.1):
|
||||
"""Press and release a key."""
|
||||
keyboard.press(key)
|
||||
time.sleep(duration)
|
||||
keyboard.release(key)
|
||||
|
||||
def type_text(text):
|
||||
"""Type a string."""
|
||||
keyboard.type(text)
|
||||
|
||||
def click(x, y, button='left'):
|
||||
"""Click at screen coordinates."""
|
||||
mouse.position = (x, y)
|
||||
time.sleep(0.05)
|
||||
btn = Button.left if button == 'left' else Button.right
|
||||
mouse.click(btn)
|
||||
|
||||
def move_mouse(dx, dy):
|
||||
"""Move mouse by delta (for camera look)."""
|
||||
cx, cy = mouse.position
|
||||
mouse.position = (cx + dx, cy + dy)
|
||||
|
||||
def walk_forward(duration=1.0):
|
||||
"""Hold W to walk forward."""
|
||||
keyboard.press('w')
|
||||
time.sleep(duration)
|
||||
keyboard.release('w')
|
||||
|
||||
def walk_backward(duration=1.0):
|
||||
keyboard.press('s')
|
||||
time.sleep(duration)
|
||||
keyboard.release('s')
|
||||
|
||||
def strafe_left(duration=0.5):
|
||||
keyboard.press('a')
|
||||
time.sleep(duration)
|
||||
keyboard.release('a')
|
||||
|
||||
def strafe_right(duration=0.5):
|
||||
keyboard.press('d')
|
||||
time.sleep(duration)
|
||||
keyboard.release('d')
|
||||
|
||||
def jump():
|
||||
press_key('e') # OpenMW default jump
|
||||
|
||||
def attack():
|
||||
"""Left click attack."""
|
||||
mouse.click(Button.left)
|
||||
|
||||
def use():
|
||||
"""Activate / use."""
|
||||
press_key(' ') # spacebar = activate in OpenMW
|
||||
|
||||
def open_menu():
|
||||
press_key(Key.esc)
|
||||
|
||||
def open_journal():
|
||||
press_key('j')
|
||||
|
||||
def open_inventory():
|
||||
press_key('i')
|
||||
|
||||
def look_around(yaw_degrees=90):
|
||||
"""Rotate camera by moving mouse."""
|
||||
# Rough: ~5 pixels per degree at default sensitivity
|
||||
move_mouse(int(yaw_degrees * 5), 0)
|
||||
|
||||
def look_up(degrees=30):
|
||||
move_mouse(0, int(-degrees * 5))
|
||||
|
||||
def look_down(degrees=30):
|
||||
move_mouse(0, int(degrees * 5))
|
||||
|
||||
|
||||
def see():
|
||||
"""Take a screenshot, OCR it, return structured perception."""
|
||||
bring_to_front()
|
||||
time.sleep(0.3)
|
||||
result = screenshot()
|
||||
if not result:
|
||||
return {"error": "screenshot failed"}
|
||||
path, w, h = result
|
||||
texts = ocr(path)
|
||||
return {
|
||||
"screenshot": path,
|
||||
"resolution": f"{w}x{h}",
|
||||
"text": texts,
|
||||
"text_summary": " | ".join(t["text"] for t in texts[:20]),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Timmy's Morrowind Eyes ===")
|
||||
bring_to_front()
|
||||
time.sleep(1)
|
||||
|
||||
perception = see()
|
||||
print(f"Resolution: {perception['resolution']}")
|
||||
print(f"Text found: {len(perception['text'])} elements")
|
||||
print(f"Summary: {perception['text_summary'][:500]}")
|
||||
10
morrowind/settings.cfg
Normal file
10
morrowind/settings.cfg
Normal file
@@ -0,0 +1,10 @@
|
||||
# This is the OpenMW user 'settings.cfg' file. This file only contains
|
||||
# explicitly changed settings. If you would like to revert a setting
|
||||
# to its default, simply remove it from this file.
|
||||
# For available settings, see the file 'files/settings-default.cfg' in our source repo or the documentation at:
|
||||
#
|
||||
# https://openmw.readthedocs.io/en/master/reference/modding/settings/index.html
|
||||
|
||||
[Video]
|
||||
resolution x = 3456
|
||||
resolution y = 2168
|
||||
Reference in New Issue
Block a user