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)
339 lines
12 KiB
Python
339 lines
12 KiB
Python
#!/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()
|