feat: bootstrap local Evennia world lane for Timmy (#36)

This commit is contained in:
Alexander Whitestone
2026-03-28 13:33:26 -04:00
parent 812c576a7b
commit 78ec5ad97b
11 changed files with 674 additions and 0 deletions

14
scripts/evennia/README.md Normal file
View File

@@ -0,0 +1,14 @@
# Evennia world lane
Local runtime target:
- `~/.timmy/evennia/timmy_world`
Main commands:
- `python3 scripts/evennia/bootstrap_local_evennia.py`
- `python3 scripts/evennia/verify_local_evennia.py`
Hermes control path:
- `scripts/evennia/evennia_mcp_server.py`
- intended MCP server key: `evennia`
- Timmy acts through a real telnet session to localhost:4000
- operator watches through localhost:4001 and/or telnet

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
RUNTIME_ROOT = Path.home() / ".timmy" / "evennia"
GAME_DIR = RUNTIME_ROOT / "timmy_world"
VENV_DIR = RUNTIME_ROOT / "venv"
EVENNIA_BIN = VENV_DIR / "bin" / "evennia"
PYTHON_BIN = VENV_DIR / "bin" / "python3"
OPERATOR_USER = os.environ.get("TIMMY_EVENNIA_OPERATOR", "Alexander")
OPERATOR_EMAIL = os.environ.get("TIMMY_EVENNIA_OPERATOR_EMAIL", "alexpaynex@gmail.com")
OPERATOR_PASSWORD = os.environ.get("TIMMY_EVENNIA_OPERATOR_PASSWORD", "timmy-local-dev")
TIMMY_PASSWORD = os.environ.get("TIMMY_EVENNIA_TIMMY_PASSWORD", "timmy-world-dev")
def ensure_venv():
if not PYTHON_BIN.exists():
subprocess.run([sys.executable, "-m", "venv", str(VENV_DIR)], check=True)
subprocess.run([str(PYTHON_BIN), "-m", "pip", "install", "--upgrade", "pip"], check=True)
subprocess.run([str(PYTHON_BIN), "-m", "pip", "install", "evennia"], check=True)
def ensure_game_dir():
if not GAME_DIR.exists():
RUNTIME_ROOT.mkdir(parents=True, exist_ok=True)
subprocess.run([str(EVENNIA_BIN), "--init", "timmy_world"], cwd=RUNTIME_ROOT, check=True)
def run_evennia(args, env=None, timeout=600):
result = subprocess.run([str(EVENNIA_BIN), *args], cwd=GAME_DIR, env=env, timeout=timeout, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(result.stderr or result.stdout)
return result.stdout
def ensure_db_and_owner():
run_evennia(["migrate"])
env = os.environ.copy()
env.update(
{
"EVENNIA_SUPERUSER_USERNAME": OPERATOR_USER,
"EVENNIA_SUPERUSER_EMAIL": OPERATOR_EMAIL,
"EVENNIA_SUPERUSER_PASSWORD": OPERATOR_PASSWORD,
}
)
run_evennia(["shell", "-c", "from evennia.accounts.models import AccountDB; print(AccountDB.objects.count())"], env=env)
def ensure_server_started():
run_evennia(["start"], timeout=240)
def run_shell(code: str):
env = os.environ.copy()
env["PYTHONPATH"] = str(REPO_ROOT) + os.pathsep + env.get("PYTHONPATH", "")
return run_evennia(["shell", "-c", code], env=env)
def ensure_timmy_and_world():
code = f'''
import sys
sys.path.insert(0, {str(REPO_ROOT)!r})
from evennia.accounts.models import AccountDB
from evennia.accounts.accounts import DefaultAccount
from evennia import DefaultRoom, DefaultExit, create_object
from evennia.utils.search import search_object
from evennia_tools.layout import ROOMS, EXITS, OBJECTS
from typeclasses.objects import Object
acc = AccountDB.objects.filter(username__iexact="Timmy").first()
if not acc:
acc, errs = DefaultAccount.create(username="Timmy", password={TIMMY_PASSWORD!r})
room_map = {{}}
for room in ROOMS:
found = search_object(room.key, exact=True)
obj = found[0] if found else None
if obj is None:
obj, errs = DefaultRoom.create(room.key, description=room.desc)
else:
obj.db.desc = room.desc
room_map[room.key] = obj
for ex in EXITS:
source = room_map[ex.source]
dest = room_map[ex.destination]
found = [obj for obj in source.contents if obj.key == ex.key and getattr(obj, "destination", None) == dest]
if not found:
DefaultExit.create(ex.key, source, dest, description=f"Exit to {{dest.key}}.", aliases=list(ex.aliases))
for spec in OBJECTS:
location = room_map[spec.location]
found = [obj for obj in location.contents if obj.key == spec.key]
if not found:
obj = create_object(typeclass=Object, key=spec.key, location=location)
else:
obj = found[0]
obj.db.desc = spec.desc
char = list(acc.characters)[0]
char.location = room_map["Gate"]
char.home = room_map["Gate"]
char.save()
print("WORLD_OK")
print("TIMMY_LOCATION", char.location.key)
'''
return run_shell(code)
def verify():
status = run_evennia(["status"], timeout=120)
info = run_evennia(["info"], timeout=120)
web = subprocess.run(
[sys.executable, "-c", "import urllib.request; r=urllib.request.urlopen('http://127.0.0.1:4001', timeout=10); print(r.status); print(r.headers.get('Content-Type'))"],
timeout=30,
capture_output=True,
text=True,
check=True,
)
return {"status": status.strip(), "info": info.strip(), "web": web.stdout.strip()}
def main():
ensure_venv()
ensure_game_dir()
ensure_db_and_owner()
ensure_server_started()
provision = ensure_timmy_and_world()
proof = verify()
print(json.dumps({"provision_stdout": provision, "verification": proof}, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import sys
import telnetlib
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
from evennia_tools.telemetry import append_event, excerpt
HOST = "127.0.0.1"
PORT = 4000
TIMMY_PASSWORD = os.environ.get("TIMMY_EVENNIA_TIMMY_PASSWORD", "timmy-world-dev")
SESSIONS: dict[str, telnetlib.Telnet] = {}
STATE_DIR = Path.home() / ".timmy" / "training-data" / "evennia"
STATE_FILE = STATE_DIR / "mcp_state.json"
app = Server("evennia")
def _load_bound_session_id() -> str:
try:
if STATE_FILE.exists():
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
return (data.get("bound_session_id") or "unbound").strip() or "unbound"
except Exception:
pass
return "unbound"
def _save_bound_session_id(session_id: str) -> str:
STATE_DIR.mkdir(parents=True, exist_ok=True)
session_id = (session_id or "unbound").strip() or "unbound"
STATE_FILE.write_text(json.dumps({"bound_session_id": session_id}, indent=2), encoding="utf-8")
return session_id
def _read(tn: telnetlib.Telnet, delay: float = 0.5) -> str:
time.sleep(delay)
return tn.read_very_eager().decode("utf-8", "ignore")
def _connect(name: str = "timmy", username: str = "Timmy", password: str | None = None) -> dict:
if name in SESSIONS:
return {"connected": True, "name": name, "output": ""}
tn = telnetlib.Telnet(HOST, PORT, timeout=10)
banner = _read(tn, 1.0)
tn.write(f"connect {username} {password or TIMMY_PASSWORD}\n".encode())
out = _read(tn, 1.0)
SESSIONS[name] = tn
append_event(_load_bound_session_id(), {"event": "connect", "actor": username, "output": excerpt(banner + "\n" + out)})
return {"connected": True, "name": name, "output": banner + "\n" + out}
def _observe(name: str = "timmy") -> dict:
if name not in SESSIONS:
_connect(name=name)
tn = SESSIONS[name]
return {"name": name, "output": _read(tn, 0.2)}
def _command(command: str, name: str = "timmy", wait_ms: int = 500) -> dict:
if name not in SESSIONS:
_connect(name=name)
tn = SESSIONS[name]
tn.write((command + "\n").encode())
out = _read(tn, max(0.1, wait_ms / 1000.0))
append_event(_load_bound_session_id(), {"event": "command", "actor": name, "command": command, "output": excerpt(out, 1000)})
return {"name": name, "command": command, "output": out}
def _disconnect(name: str = "timmy") -> dict:
tn = SESSIONS.pop(name, None)
if tn:
try:
tn.close()
except Exception:
pass
append_event(_load_bound_session_id(), {"event": "disconnect", "actor": name})
return {"disconnected": bool(tn), "name": name}
@app.list_tools()
async def list_tools():
return [
Tool(name="bind_session", description="Bind a Hermes session id to Evennia telemetry logs.", inputSchema={"type": "object", "properties": {"session_id": {"type": "string"}}, "required": ["session_id"]}),
Tool(name="status", description="Show Evennia MCP/telnet control status.", inputSchema={"type": "object", "properties": {}, "required": []}),
Tool(name="connect", description="Connect Timmy to the local Evennia telnet server as a real in-world account.", inputSchema={"type": "object", "properties": {"name": {"type": "string"}, "username": {"type": "string"}, "password": {"type": "string"}}, "required": []}),
Tool(name="observe", description="Read pending text output from Timmy's Evennia connection.", inputSchema={"type": "object", "properties": {"name": {"type": "string"}}, "required": []}),
Tool(name="command", description="Send one Evennia command line as Timmy and return the resulting text output.", inputSchema={"type": "object", "properties": {"command": {"type": "string"}, "name": {"type": "string"}, "wait_ms": {"type": "integer", "default": 500}}, "required": ["command"]}),
Tool(name="disconnect", description="Close Timmy's Evennia telnet control session.", inputSchema={"type": "object", "properties": {"name": {"type": "string"}}, "required": []}),
]
@app.call_tool()
async def call_tool(name: str, arguments: dict):
arguments = arguments or {}
if name == "bind_session":
bound = _save_bound_session_id(arguments.get("session_id", "unbound"))
result = {"bound_session_id": bound}
elif name == "status":
result = {"connected_sessions": sorted(SESSIONS.keys()), "bound_session_id": _load_bound_session_id()}
elif name == "connect":
result = _connect(arguments.get("name", "timmy"), arguments.get("username", "Timmy"), arguments.get("password"))
elif name == "observe":
result = _observe(arguments.get("name", "timmy"))
elif name == "command":
result = _command(arguments.get("command", ""), arguments.get("name", "timmy"), arguments.get("wait_ms", 500))
elif name == "disconnect":
result = _disconnect(arguments.get("name", "timmy"))
else:
result = {"error": f"Unknown tool: {name}"}
return [TextContent(type="text", text=json.dumps(result, indent=2))]
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())

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes-local}"
SRC_CFG="$HOME/.hermes/config.yaml"
DST_CFG="$HERMES_HOME/config.yaml"
REPO_ROOT="${REPO_ROOT:-$HOME/.timmy}"
mkdir -p "$HERMES_HOME"
if [ ! -f "$DST_CFG" ]; then
cp "$SRC_CFG" "$DST_CFG"
fi
if [ ! -e "$HERMES_HOME/skins" ]; then
ln -s "$HOME/.hermes/skins" "$HERMES_HOME/skins"
fi
python3 - <<'PY'
from pathlib import Path
import yaml
home = Path.home()
cfg_path = home / '.hermes-local' / 'config.yaml'
data = yaml.safe_load(cfg_path.read_text()) or {}
model = data.get('model')
if not isinstance(model, dict):
model = {'default': str(model)} if model else {}
data['model'] = model
model['default'] = 'NousResearch_Hermes-4-14B-Q4_K_M.gguf'
model['provider'] = 'custom'
model['base_url'] = 'http://localhost:8081/v1'
model['context_length'] = 65536
providers = data.get('custom_providers')
if not isinstance(providers, list):
providers = []
data['custom_providers'] = providers
found = False
for entry in providers:
if isinstance(entry, dict) and entry.get('name') == 'Local llama.cpp':
entry['base_url'] = 'http://localhost:8081/v1'
entry['api_key'] = 'none'
entry['model'] = 'NousResearch_Hermes-4-14B-Q4_K_M.gguf'
found = True
break
if not found:
providers.insert(0, {
'name': 'Local llama.cpp',
'base_url': 'http://localhost:8081/v1',
'api_key': 'none',
'model': 'NousResearch_Hermes-4-14B-Q4_K_M.gguf',
})
import os
repo_root = Path(os.environ.get('REPO_ROOT', str(Path.home() / '.timmy'))).expanduser()
script_path = repo_root / 'scripts' / 'evennia' / 'evennia_mcp_server.py'
data['mcp_servers'] = {
'evennia': {
'command': 'python3',
'args': [str(script_path)],
'env': {},
'timeout': 30,
}
}
cfg_path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True))
PY
exec hermes chat "$@"

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import telnetlib
import time
import urllib.request
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
ROOT = Path.home() / ".timmy" / "evennia" / "timmy_world"
EVENNIA_BIN = Path.home() / ".timmy" / "evennia" / "venv" / "bin" / "evennia"
TIMMY_PASSWORD = os.environ.get("TIMMY_EVENNIA_TIMMY_PASSWORD", "timmy-world-dev")
def shell_json(code: str) -> dict:
env = dict(**os.environ)
env["PYTHONPATH"] = str(REPO_ROOT) + ":" + env.get("PYTHONPATH", "")
result = subprocess.run([str(EVENNIA_BIN), "shell", "-c", code], cwd=ROOT, env=env, capture_output=True, text=True, timeout=120, check=True)
lines = [line for line in result.stdout.splitlines() if line.strip()]
return json.loads(lines[-1])
def telnet_roundtrip() -> str:
tn = telnetlib.Telnet("127.0.0.1", 4000, timeout=10)
time.sleep(1.0)
_ = tn.read_very_eager().decode("utf-8", "ignore")
tn.write(f"connect Timmy {TIMMY_PASSWORD}\n".encode())
time.sleep(1.0)
login_out = tn.read_very_eager().decode("utf-8", "ignore")
tn.write(b"look\n")
time.sleep(0.6)
look_out = tn.read_very_eager().decode("utf-8", "ignore")
tn.close()
return " ".join((login_out + "\n" + look_out).split())[:600]
def main():
result = {"game_dir_exists": ROOT.exists()}
try:
with urllib.request.urlopen("http://127.0.0.1:4001", timeout=10) as r:
result["web_status"] = r.status
result["web_content_type"] = r.headers.get("Content-Type")
except Exception as e:
result["web_error"] = f"{type(e).__name__}: {e}"
try:
state = shell_json(
"from evennia.accounts.models import AccountDB; from evennia.utils.search import search_object; "
"acc=AccountDB.objects.get(username='Timmy'); char=list(acc.characters)[0]; rooms=['Gate','Courtyard','Workshop','Archive','Chapel']; "
"import json; print(json.dumps({'timmy_home': getattr(char.home,'key',None), 'rooms': {name: bool(search_object(name, exact=True)) for name in rooms}}))"
)
result.update(state)
except Exception as e:
result["world_error"] = f"{type(e).__name__}: {e}"
try:
result["telnet_roundtrip_excerpt"] = telnet_roundtrip()
except Exception as e:
result["telnet_error"] = f"{type(e).__name__}: {e}"
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()