Files
timmy-home/morrowind/local_brain.py

339 lines
12 KiB
Python
Raw Permalink Normal View History

#!/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()